Compare commits
60 Commits
dev
...
283-provid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e75c3edf | ||
| f50d57370f | |||
| 360d20e881 | |||
| 023274c46c | |||
| 2952e5ad3e | |||
| 210508db9d | |||
| 670c46dedd | |||
| e64bae9cfc | |||
| bf561b867c | |||
| c44f683aa6 | |||
| 1e0f21365b | |||
| 50bc44cfa0 | |||
| b2ec2f032f | |||
| a146b14208 | |||
| 71596ae590 | |||
| 35b59eb628 | |||
| ca61fa17dc | |||
| 867bd92370 | |||
| 6bf8e7f76b | |||
| 3aeb0d04b8 | |||
| 23ef20f86d | |||
|
|
df5a0e067d | ||
|
|
15af199d4f | ||
| 11247c1537 | |||
| b05d5c52d4 | |||
| 8f1ceb70ec | |||
| 25e1f69513 | |||
| feeaadd5ad | |||
| bcabb14480 | |||
| eae06bfe05 | |||
| 866875559f | |||
|
|
0517305381 | ||
| 966b7af472 | |||
|
|
1bf369b561 | ||
|
|
a2bb5b7729 | ||
|
|
bb78049271 | ||
| 7d17d39060 | |||
|
|
a35cd88bff | ||
| 926b0fe4f3 | |||
|
|
a74a6791ad | ||
| 52ebf63af1 | |||
|
|
2e2b125107 | ||
|
|
4b0dc2a62e | ||
|
|
34351a281d | ||
| 51ea80ca05 | |||
|
|
e36bd3ca9c | ||
| b511b08371 | |||
|
|
f53f149f99 | ||
| 2fa8fc0f87 | |||
|
|
44e6a1eb05 | ||
|
|
4f7c1a6c94 | ||
|
|
4325e1ed8d | ||
|
|
4ae4c2ee95 | ||
|
|
32b6dcb937 | ||
|
|
f7bc4f2787 | ||
|
|
0739018ee5 | ||
|
|
9a02261f5c | ||
|
|
65ec1d5904 | ||
|
|
f05857c276 | ||
|
|
9f5d3293c5 |
@ -4,11 +4,21 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "vendor/bin/sail",
|
||||
"command": "/Users/ahmeddarrazi/Documents/projects/wt-plattform/scripts/platform-sail",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"kroki": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/Users/ahmeddarrazi/Documents/projects/kroki-mcp-server/dist/index.js"
|
||||
],
|
||||
"env": {
|
||||
"KROKI_BASE_URL": "http://development-kroki-ccl69b-553648-194-164-192-109.traefik.me",
|
||||
"KROKI_TIMEOUT_MS": "10000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
.github/agents/copilot-instructions.md
vendored
18
.github/agents/copilot-instructions.md
vendored
@ -266,6 +266,18 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
|
||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
|
||||
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams (259-compliance-evidence-mapping)
|
||||
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `evidence_snapshots`, `evidence_snapshot_items`, `findings`, `finding_exceptions`, memberships, and `audit_logs`; no new persistence table planned (259-compliance-evidence-mapping)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure (260-governance-service-packaging)
|
||||
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `review_packs`, `evidence_snapshots`, `evidence_snapshot_items`, `stored_reports`, `findings`, `finding_exceptions`, `finding_exception_decisions`, memberships, and `audit_logs`; no new persistence planned (260-governance-service-packaging)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Tailwind v4, Pest v4, existing dashboard widgets, `TenantGovernanceAggregateResolver`, `RestoreSafetyResolver`, `BackupHealthDashboardSignal`, `OperationRunLinks`, `RequiredPermissionsLinks`, `TenantRequiredPermissionsViewModelBuilder`, tenant review/evidence/review-pack resources, shared badge rendering, and capability helpers (266-tenant-dashboard-productization-v1)
|
||||
- PostgreSQL via existing tenant-owned findings, exceptions, operation runs, evidence snapshots, review packs, tenant reviews, backup or restore evidence records, memberships, and audit logs; no new persistence planned (266-tenant-dashboard-productization-v1)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers (275-customer-facing-localization-adoption)
|
||||
- PostgreSQL via existing `users.preferred_locale`, existing workspace localization setting, existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, memberships, and `audit_logs`; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de`; no new persistence planned (275-customer-facing-localization-adoption)
|
||||
- Markdown and YAML planning artifacts over PHP 8.4 / Laravel 12 source anchors + `spec.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, nearby Specs `270`, `275`, and `277`, and repo-real source anchors such as `OperationUxPresenter`, `InventoryKpiHeader`, `RecoveryReadiness`, `BaselineSnapshotPresenter`, `ReviewPackService`, and `TenantDashboardSummaryBuilder` (278-cross-domain-indicator-audit)
|
||||
- Repository files only; no database or runtime persistence changes (278-cross-domain-indicator-audit)
|
||||
- PHP 8.4.15, Laravel 12.52 + Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing workspace and environment authorization/context helpers, existing Filament panel providers and page/resource action-surface contracts (280-workspace-tenancy-environment-routing)
|
||||
- PostgreSQL, no new persistence or schema change in this slice (280-workspace-tenancy-environment-routing)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -300,9 +312,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
|
||||
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
|
||||
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
|
||||
- 280-workspace-tenancy-environment-routing: Added PHP 8.4.15, Laravel 12.52 + Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing workspace and environment authorization/context helpers, existing Filament panel providers and page/resource action-surface contracts
|
||||
- 278-cross-domain-indicator-audit: Added Markdown and YAML planning artifacts over PHP 8.4 / Laravel 12 source anchors + `spec.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, nearby Specs `270`, `275`, and `277`, and repo-real source anchors such as `OperationUxPresenter`, `InventoryKpiHeader`, `RecoveryReadiness`, `BaselineSnapshotPresenter`, `ReviewPackService`, and `TenantDashboardSummaryBuilder`
|
||||
- 275-customer-facing-localization-adoption: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
2
.github/skills/giteaflow/SKILL.md
vendored
2
.github/skills/giteaflow/SKILL.md
vendored
@ -5,4 +5,4 @@
|
||||
|
||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||
|
||||
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
||||
comit all changes, push to remote, and create a pull request against platform-dev with gitea mcp
|
||||
92
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
92
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
@ -85,6 +85,9 @@ ## Hard Rules
|
||||
- Do not run destructive commands.
|
||||
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||
- Do not overwrite existing specs.
|
||||
- Do not rewrite completed specs back into preparation state.
|
||||
- Do not remove or normalize implementation history, close-out notes, validation results, completed task markers, smoke results, or post-implementation review language from completed specs.
|
||||
- Treat completed-spec close-out and validation language as intentional repository history, not preparation drift.
|
||||
- Do not move from preparation to an implementation step inside this skill.
|
||||
|
||||
## Required Inputs
|
||||
@ -119,6 +122,32 @@ ## Required Repository Checks
|
||||
|
||||
Do not edit application code.
|
||||
|
||||
## Completed-Spec Guardrail
|
||||
|
||||
Before selecting an existing spec package as a `next-best-prep` target, explicitly check whether the spec is already completed, implementation-closed, or validated.
|
||||
|
||||
A spec must be treated as completed if any of the following signals are present in `spec.md`, `plan.md`, `tasks.md`, `quickstart.md`, checklist artifacts, or related Spec Kit package files:
|
||||
|
||||
- `Implementation Close-Out`
|
||||
- `Implementation completed on`
|
||||
- `Implementation Validation Results`
|
||||
- `Implemented and validated`
|
||||
- `Review Outcome` or `Implementation Review Outcome`
|
||||
- passed validation, smoke, browser, or guardrail results
|
||||
- completed task checklist markers for the implementation tasks
|
||||
- post-implementation review or close-out language
|
||||
- a status marker indicating implemented, completed, closed, or validated
|
||||
|
||||
If a spec is completed:
|
||||
|
||||
- exclude it from `next-best-prep` candidate selection
|
||||
- do not patch, normalize, rewrite, or convert it back to preparation-only state
|
||||
- do not remove close-out sections, validation results, completed task markers, smoke results, or post-implementation review language
|
||||
- treat those artifacts as historical implementation evidence
|
||||
- only use the completed spec as context for dependency or roadmap reasoning
|
||||
|
||||
If all high-priority candidates are already specced, active, or completed, stop and report `no safe next prep target` instead of modifying existing completed specs.
|
||||
|
||||
## Git and Branch Safety
|
||||
|
||||
Before running any Spec Kit command:
|
||||
@ -143,6 +172,7 @@ ### Gate 1: Candidate Selection Gate
|
||||
|
||||
- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user.
|
||||
- The selected candidate is not already covered by an existing active or completed spec.
|
||||
- The selected target is not a completed spec package with implementation close-out, validation results, completed tasks, smoke results, or post-implementation review history.
|
||||
- The selected candidate aligns with current roadmap priorities or explicitly documented product direction.
|
||||
- The candidate can be scoped as a small, reviewable, implementation-ready slice.
|
||||
- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope.
|
||||
@ -150,6 +180,7 @@ ### Gate 1: Candidate Selection Gate
|
||||
Fail behavior:
|
||||
|
||||
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
|
||||
- If the only plausible targets are completed specs, stop and report `no safe next prep target`; do not modify those completed specs.
|
||||
- Do not invent a new roadmap direction to force progress.
|
||||
|
||||
### Gate 2: Spec Readiness Gate
|
||||
@ -180,6 +211,8 @@ ## Candidate Selection Rules
|
||||
- Read `docs/product/spec-candidates.md`.
|
||||
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
||||
- Check existing specs to avoid duplicates.
|
||||
- Check existing specs for completed-spec signals before selecting an existing package as a refresh target.
|
||||
- Exclude completed specs from next-best-prep selection, even if their artifacts contain close-out, validation, or completed-task language that would look like drift in a preparation-only package.
|
||||
- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity.
|
||||
- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
||||
- Prefer small, implementation-ready slices over broad platform rewrites.
|
||||
@ -198,6 +231,7 @@ ## Candidate Selection Rules
|
||||
5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely?
|
||||
6. **Risk Reduction**: Does it reduce current architectural or product risk?
|
||||
7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope?
|
||||
8. **Completion Safety**: Is the target genuinely unprepared or incomplete, rather than an already completed spec whose historical close-out artifacts should be preserved?
|
||||
|
||||
## Required Selection Output Before Spec Kit Execution
|
||||
|
||||
@ -208,6 +242,7 @@ ## Required Selection Output Before Spec Kit Execution
|
||||
- why it was selected
|
||||
- why close alternatives were deferred
|
||||
- roadmap relationship
|
||||
- completed-spec check result for related existing specs
|
||||
- smallest viable implementation slice
|
||||
- proposed concise feature description to feed into `specify`
|
||||
|
||||
@ -296,7 +331,7 @@ ### Step 5: Run preparation `analyze`
|
||||
|
||||
### Step 6: Fix preparation-artifact issues only
|
||||
|
||||
If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
||||
If preparation analyze finds issues, first confirm that the selected package is not completed. Then fix only Spec Kit preparation artifacts such as:
|
||||
|
||||
- `spec.md`
|
||||
- `plan.md`
|
||||
@ -322,6 +357,10 @@ ### Step 6: Fix preparation-artifact issues only
|
||||
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
|
||||
- running implementation or test-fix loops
|
||||
- changing runtime behavior
|
||||
- removing implementation close-out history from completed specs
|
||||
- converting completed specs back to preparation-only wording
|
||||
- changing passed validation or smoke results into planned validation commands
|
||||
- unchecking completed implementation tasks in a completed spec
|
||||
|
||||
### Step 7: Evaluate the Spec Readiness Gate
|
||||
|
||||
@ -478,23 +517,33 @@ ## Failure Handling
|
||||
2. Report the current branch and relevant uncommitted files.
|
||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||
|
||||
If a completed spec is accidentally selected or modified:
|
||||
|
||||
1. Stop immediately.
|
||||
2. Report that the selected spec is completed and therefore not a valid preparation target.
|
||||
3. Revert only the changes made by this operation to that completed spec package, if they are isolated and safe to revert.
|
||||
4. Run `git status --short` and report remaining changes.
|
||||
5. Re-run candidate selection excluding completed specs.
|
||||
6. If no safe unprepared candidate exists, report `no safe next prep target`.
|
||||
|
||||
## Final Response Requirements
|
||||
|
||||
Respond with:
|
||||
|
||||
1. Selected candidate and why it was chosen
|
||||
2. Why close alternatives were deferred
|
||||
3. Current branch after Spec Kit execution, if changed
|
||||
4. Generated spec path
|
||||
5. Files created or updated by Spec Kit
|
||||
6. Preparation analyze result summary
|
||||
7. Preparation-artifact fixes applied after analyze
|
||||
8. Assumptions made
|
||||
9. Open questions, if any
|
||||
10. Candidate Selection Gate result
|
||||
11. Spec Readiness Gate result
|
||||
12. Recommended next implementation prompt
|
||||
13. Explicit statement that no application implementation was performed
|
||||
3. Completed-spec guardrail result for related existing specs
|
||||
4. Current branch after Spec Kit execution, if changed
|
||||
5. Generated spec path
|
||||
6. Files created or updated by Spec Kit
|
||||
7. Preparation analyze result summary
|
||||
8. Preparation-artifact fixes applied after analyze
|
||||
9. Assumptions made
|
||||
10. Open questions, if any
|
||||
11. Candidate Selection Gate result
|
||||
12. Spec Readiness Gate result
|
||||
13. Recommended next implementation prompt
|
||||
14. Explicit statement that no application implementation was performed
|
||||
|
||||
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||
|
||||
@ -550,13 +599,14 @@ ## Example Invocation
|
||||
2. Check branch and working tree safety.
|
||||
3. Compare candidate suitability.
|
||||
4. Select the next best candidate.
|
||||
5. Evaluate the Candidate Selection Gate.
|
||||
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||
7. Run the repository's real Spec Kit `plan` flow.
|
||||
8. Run the repository's real Spec Kit `tasks` flow.
|
||||
9. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||
10. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||
11. Evaluate the Spec Readiness Gate.
|
||||
12. Stop before application implementation.
|
||||
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
||||
5. Exclude already completed specs from preparation or refresh targets, preserving their close-out and validation history.
|
||||
6. Evaluate the Candidate Selection Gate.
|
||||
7. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||
8. Run the repository's real Spec Kit `plan` flow.
|
||||
9. Run the repository's real Spec Kit `tasks` flow.
|
||||
10. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||
11. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||
12. Evaluate the Spec Readiness Gate.
|
||||
13. Stop before application implementation.
|
||||
14. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
||||
```
|
||||
@ -1,34 +1,35 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.10.0 -> 2.11.0
|
||||
- Version change: 2.12.0 -> 2.13.0
|
||||
- Modified principles:
|
||||
- Expanded decision-first and operator-surface rules so operational,
|
||||
governance, evidence, onboarding, review, and support-facing
|
||||
detail/status surfaces separate decision content, operator
|
||||
diagnostics, and support/raw evidence
|
||||
- Expanded review and enforcement expectations so specs, plans,
|
||||
tasks, and checklists must make audience modes, raw/support
|
||||
gating, one dominant next action, and duplicate-truth prevention
|
||||
explicit
|
||||
- Added sections:
|
||||
- Audience-Aware Decision Surfaces & Disclosure Ladder
|
||||
(DECIDE-AUD-001): requires customer-readable default paths,
|
||||
operator diagnostics as progressive disclosure, support/raw
|
||||
evidence gating, one dominant next action, and no duplicate truth
|
||||
across equal-priority cards
|
||||
- Expanded Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
so custom Filament UI must follow the canonical TenantPilot
|
||||
enterprise UI standard, must not introduce ad-hoc styling for
|
||||
cards, buttons, hovers, badges, icons, progress bars, empty states,
|
||||
or interactive rows, and may only show interactive affordance when
|
||||
a repo-real route/action and permitted capability exist
|
||||
- Added sections: None
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- .specify/templates/spec-template.md: add audience-aware disclosure
|
||||
section + constitution prompts ✅
|
||||
- .specify/templates/plan-template.md: add audience/disclosure
|
||||
planning prompts + constitution checks ✅
|
||||
- .specify/templates/tasks-template.md: add decision/disclosure
|
||||
implementation + test tasks ✅
|
||||
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
||||
one-primary-action, and duplicate-truth review checks ✅
|
||||
- docs/product/standards/README.md: refresh constitution index for
|
||||
the new audience-aware disclosure contract ✅
|
||||
- .specify/templates/spec-template.md: require canonical UI-standard
|
||||
compliance, no ad-hoc custom styling, and repo-real affordance
|
||||
disclosure ✅
|
||||
- .specify/templates/plan-template.md: add UI-FIL-001 checks for the
|
||||
canonical UI standard and affordance honesty ✅
|
||||
- .specify/templates/tasks-template.md: add implementation tasks for
|
||||
no ad-hoc styling and repo-real interactive affordances ✅
|
||||
- .specify/templates/checklist-template.md: add explicit custom UI
|
||||
standard and affordance review check ✅
|
||||
- docs/product/principles.md: align the high-level product rule with
|
||||
the canonical UI standard ✅
|
||||
- docs/product/standards/filament-native-enterprise-ui.md: align the
|
||||
compact standard with the canonical UI source ✅
|
||||
- docs/product/standards/README.md: index the canonical UI-standard
|
||||
document ✅
|
||||
- docs/HANDOVER.md: refresh the Filament standards summary ✅
|
||||
- docs/ui/tenantpilot-enterprise-ui-standards.md: fix the
|
||||
constitution-reference path to the canonical file ✅
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||
- Follow-up TODOs: None
|
||||
@ -1710,6 +1711,7 @@ ### Badge Semantics Are Centralized (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.
|
||||
- TenantPilot custom Filament UI MUST follow `docs/ui/tenantpilot-enterprise-ui-standards.md`. When this constitution gives a shorter rule, that document remains the canonical detailed standard for custom Filament affordance, styling, hierarchy, and disclosure patterns.
|
||||
- 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.
|
||||
- Local Blade/Tailwind cards are allowed only when they preserve dark
|
||||
@ -1717,6 +1719,39 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
hierarchy, progressive disclosure, accessibility, and overall
|
||||
Filament visual language.
|
||||
|
||||
Enterprise consistency for custom surfaces
|
||||
- TenantPilot custom Blade, Livewire widget, Filament page, and
|
||||
productized dashboard/detail surfaces MUST preserve Filament-native
|
||||
interaction semantics unless the governing spec records a bounded
|
||||
product reason to diverge.
|
||||
- Custom surfaces MUST NOT introduce independent button systems,
|
||||
status color semantics, spacing systems, or card styles that
|
||||
function as a parallel local design system.
|
||||
- Feature specs and implementation MUST NOT introduce ad-hoc custom
|
||||
styling for cards, buttons, hovers, badges, icons, progress bars,
|
||||
empty states, or interactive rows.
|
||||
- Each page, card cluster, or other focused action area MUST keep at
|
||||
most one dominant primary action. Secondary actions MUST remain
|
||||
neutral unless the action is destructive or the semantic state
|
||||
change is itself the point of the action.
|
||||
- Status, health, risk, readiness, and similar state cues MUST be
|
||||
conveyed through BADGE-001 badges, labels, chips, and supporting
|
||||
text rather than arbitrary button colors or per-card custom action
|
||||
styling.
|
||||
- Hover, pointer, focus, shadow, or similar interactive affordance MUST
|
||||
appear only when a repo-real route/action exists and the current
|
||||
actor has the permitted capability. Otherwise the surface MUST render
|
||||
as static and non-interactive.
|
||||
- Custom Blade/Tailwind composition MUST be used to arrange
|
||||
product-specific layout, decision hierarchy, and progressive
|
||||
disclosure, not to redefine semantic action, status, or container
|
||||
primitives that Filament or shared project primitives already
|
||||
standardize.
|
||||
- Per-card custom action styling, status-colored non-status actions,
|
||||
and oversized custom borders, shadows, or spacing that visually
|
||||
detach a surface from the Filament panel are forbidden unless
|
||||
UI-EX-001 records a bounded exception.
|
||||
|
||||
Native-by-default classification
|
||||
- `Native Surface` means the primary interaction contract is built from
|
||||
Filament-native components or approved shared primitives.
|
||||
@ -1835,4 +1870,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
|
||||
**Version**: 2.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-05-03
|
||||
|
||||
@ -22,6 +22,7 @@ ## Applicability And Low-Impact Gate
|
||||
## Native, Shared-Family, And State Ownership
|
||||
|
||||
- [ ] CHK003 The surface remains native/shared-primitives first; fake-native controls, GET-form page-body interactions, and simple-overview replacements are not treated as harmless customization.
|
||||
- [ ] CHK028 Custom Blade, Livewire widget, and dashboard/detail surfaces follow `docs/ui/tenantpilot-enterprise-ui-standards.md`: they do not invent an independent button, status-color, spacing, or card system, they do not add ad-hoc styling for cards/buttons/hovers/badges/icons/progress bars/empty states/interactive rows, status stays badge/label/supporting-text first, each focused area keeps one dominant primary action, and interactive affordance exists only when a repo-real route/action and permitted capability exist.
|
||||
- [ ] CHK004 Any shared-detail or shared-family surface keeps one shared contract, and any host variation is either folded back into that contract or explicitly bounded as an exception.
|
||||
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
|
||||
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
|
||||
|
||||
@ -115,10 +115,21 @@ ## Constitution Check
|
||||
- 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
|
||||
- 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
|
||||
- Filament-native UI (UI-FIL-001): custom Filament UI follows `docs/ui/tenantpilot-enterprise-ui-standards.md`; no ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, or interactive rows
|
||||
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
|
||||
still necessary, they preserve dark mode correctness, spacing
|
||||
consistency, badge semantics, action hierarchy, progressive
|
||||
disclosure, accessibility, and Filament visual language
|
||||
- Filament-native UI (UI-FIL-001): custom Blade/Widget/Page surfaces
|
||||
keep Filament-native interaction semantics, preserve one dominant
|
||||
primary action per focused area, express state through
|
||||
BADGE-001-aligned badges/labels/supporting text instead of arbitrary
|
||||
button colors, and do not create independent button/card/spacing
|
||||
systems
|
||||
- Filament-native UI (UI-FIL-001): hover, pointer, focus, shadow, or
|
||||
similar interactive affordance appears only when a repo-real
|
||||
route/action and permitted capability exist; non-interactive rows
|
||||
stay visibly static
|
||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||
- Decision-first operating model (DECIDE-001): each changed
|
||||
operator-facing surface is classified as Primary Decision,
|
||||
|
||||
@ -325,10 +325,16 @@ ## Requirements *(mandatory)*
|
||||
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:
|
||||
- how the affected surface follows `docs/ui/tenantpilot-enterprise-ui-standards.md`,
|
||||
- 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,
|
||||
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language,
|
||||
- how the feature avoids ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, and interactive rows,
|
||||
- how any custom Blade, Livewire widget, page, or dashboard surface preserves Filament-native interaction semantics and avoids introducing an independent button, status-color, spacing, or card system,
|
||||
- how each affected page or focused action area keeps at most one dominant primary action and keeps secondary actions neutral unless they are destructive or the semantic state change is the point of the action,
|
||||
- how status is conveyed through BADGE-001 badges, labels, chips, or supporting text rather than arbitrary button colors or per-card custom action styling,
|
||||
- how hover, pointer, focus, shadow, or similar interactive affordance is used only when a repo-real route/action and permitted capability exist, and how non-interactive rows remain visibly static,
|
||||
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language, and are used to compose product-specific layout rather than a parallel local design system,
|
||||
- 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,
|
||||
|
||||
@ -132,8 +132,24 @@ # Tasks: [FEATURE NAME]
|
||||
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||
- adding `AuditLog` entries for relevant mutations,
|
||||
- following `docs/ui/tenantpilot-enterprise-ui-standards.md` for any
|
||||
custom Filament UI surface,
|
||||
- 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,
|
||||
- avoiding ad-hoc styling for cards, buttons, hovers, badges, icons,
|
||||
progress bars, empty states, and interactive rows,
|
||||
- keeping any custom Blade, Livewire widget, page, or
|
||||
dashboard/detail surface Filament-native in semantics: no
|
||||
independent button, status-color, card, or spacing system, one
|
||||
dominant primary action per focused area, and secondary actions
|
||||
neutral unless destructive or explicitly state-changing,
|
||||
- expressing status, health, risk, readiness, and similar cues through
|
||||
BADGE-001 badges, labels, chips, and supporting text rather than
|
||||
arbitrary button colors or per-card custom action styling,
|
||||
- using hover, pointer, focus, shadow, or similar interactive
|
||||
affordance only when a repo-real route/action and permitted
|
||||
capability exist, and rendering static rows without fake
|
||||
interactivity otherwise,
|
||||
- documenting any workflow-hub, wizard, utility/system, or other
|
||||
special-type exception in the spec/PR and adding dedicated test
|
||||
coverage,
|
||||
|
||||
@ -31,7 +31,8 @@ ### Platform
|
||||
- Install PHP dependencies: `cd apps/platform && composer install`
|
||||
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||
- Run migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||
- Apply incremental migrations on an already cut-over local database: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Initialize or reset the local database: `cd apps/platform && ./vendor/bin/sail artisan migrate:fresh --seed`
|
||||
- Run frontend watch/build inside Sail: `corepack pnpm dev:platform`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||
|
||||
@ -152,6 +153,7 @@ ### DB Reset and Seed Rules
|
||||
|
||||
- Default lanes use SQLite `:memory:` with `RefreshDatabase` as the reset strategy.
|
||||
- The isolated PostgreSQL coverage remains the `Pgsql` suite and is reserved for schema or foreign-key assertions.
|
||||
- Spec 279 managed-environment core cutover is destructive for old local databases. If a local database still contains legacy `tenants`, `tenant_user`, `tenant_memberships`, or `user_tenant_preferences` tables, reset it with `cd apps/platform && ./vendor/bin/sail artisan migrate:fresh --seed` instead of trying to preserve that schema.
|
||||
- Keep seeds out of default lanes. Opt into seeded fixtures only inside the test that needs business-truth seed data.
|
||||
- Schema-baseline or dump-based acceleration remains a follow-up investigation, not a default requirement for the current lane model.
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionClassificationResult;
|
||||
use App\Services\Providers\ProviderConnectionClassifier;
|
||||
@ -43,9 +43,9 @@ public function handle(ProviderConnectionClassifier $classifier): int
|
||||
}
|
||||
|
||||
$tenantCounts = (clone $query)
|
||||
->selectRaw('tenant_id, count(*) as aggregate')
|
||||
->groupBy('tenant_id')
|
||||
->pluck('aggregate', 'tenant_id')
|
||||
->selectRaw('managed_environment_id, count(*) as aggregate')
|
||||
->groupBy('managed_environment_id')
|
||||
->pluck('aggregate', 'managed_environment_id')
|
||||
->map(static fn (mixed $count): int => (int) $count)
|
||||
->all();
|
||||
|
||||
@ -84,7 +84,7 @@ public function handle(ProviderConnectionClassifier $classifier): int
|
||||
|
||||
$tenant = $connection->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
|
||||
|
||||
continue;
|
||||
@ -123,11 +123,11 @@ private function query(): Builder
|
||||
$tenantOption = $this->option('tenant');
|
||||
|
||||
if (is_string($tenantOption) && trim($tenantOption) !== '') {
|
||||
$tenant = Tenant::query()
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->forTenant(trim($tenantOption))
|
||||
->firstOrFail();
|
||||
|
||||
$query->where('tenant_id', (int) $tenant->getKey());
|
||||
$query->where('managed_environment_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
$connectionOption = $this->option('connection');
|
||||
@ -175,7 +175,7 @@ private function applyClassification(
|
||||
return $connection->fresh(['tenant', 'credential']);
|
||||
}
|
||||
|
||||
private function auditStart(Tenant $tenant, int $candidateCount): void
|
||||
private function auditStart(ManagedEnvironment $tenant, int $candidateCount): void
|
||||
{
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
@ -195,7 +195,7 @@ private function auditStart(Tenant $tenant, int $candidateCount): void
|
||||
}
|
||||
|
||||
private function auditApplied(
|
||||
Tenant $tenant,
|
||||
ManagedEnvironment $tenant,
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionClassificationResult $result,
|
||||
): void {
|
||||
|
||||
@ -15,7 +15,7 @@ class OpsReconcileAdapterRuns extends Command
|
||||
*/
|
||||
protected $signature = 'ops:reconcile-adapter-runs
|
||||
{--type= : Adapter run type (e.g. restore.execute)}
|
||||
{--tenant= : Tenant ID}
|
||||
{--tenant= : ManagedEnvironment ID}
|
||||
{--older-than=60 : Only consider runs older than N minutes}
|
||||
{--dry-run=true : Preview only (true/false)}
|
||||
{--limit=50 : Max number of runs to inspect}';
|
||||
@ -56,7 +56,7 @@ public function handle()
|
||||
|
||||
$result = $reconciler->reconcile([
|
||||
'type' => $type,
|
||||
'tenant_id' => $tenantId,
|
||||
'managed_environment_id' => $tenantId,
|
||||
'older_than_minutes' => $olderThanMinutes,
|
||||
'limit' => $limit,
|
||||
'dry_run' => $dryRun,
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
@ -51,7 +51,7 @@ public function handle(): int
|
||||
}
|
||||
|
||||
if ($tenantIds !== []) {
|
||||
$query->whereIn('tenant_id', $tenantIds);
|
||||
$query->whereIn('managed_environment_id', $tenantIds);
|
||||
}
|
||||
|
||||
$candidates = $query->get();
|
||||
@ -66,12 +66,12 @@ public function handle(): int
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
||||
['Run', 'Type', 'ManagedEnvironment', '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 : '—',
|
||||
'ManagedEnvironment' => $run->managed_environment_id !== null ? (string) $run->managed_environment_id : '—',
|
||||
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
||||
'Legacy signal' => $this->legacySignal($run),
|
||||
])
|
||||
@ -145,9 +145,9 @@ private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||
$tenant = ManagedEnvironment::query()->forTenant($identifier)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -43,14 +43,14 @@ public function handle(): int
|
||||
->where('policy_type', 'enrollmentRestriction');
|
||||
|
||||
if ($tenant) {
|
||||
$query->where('tenant_id', $tenant->id);
|
||||
$query->where('managed_environment_id', $tenant->id);
|
||||
}
|
||||
|
||||
$candidates = $query->get();
|
||||
|
||||
$changedVersions = 0;
|
||||
$changedPolicies = 0;
|
||||
$ignoredPolicies = 0;
|
||||
$providerMissingPolicies = 0;
|
||||
|
||||
foreach ($candidates as $policy) {
|
||||
$latestVersion = $policy->versions()->latest('version_number')->first();
|
||||
@ -69,9 +69,9 @@ public function handle(): int
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'ESP detected: policy=%s tenant_id=%s external_id=%s',
|
||||
'ESP detected: policy=%s managed_environment_id=%s external_id=%s',
|
||||
(string) $policy->getKey(),
|
||||
(string) $policy->tenant_id,
|
||||
(string) $policy->managed_environment_id,
|
||||
(string) $policy->external_id,
|
||||
));
|
||||
|
||||
@ -80,20 +80,21 @@ public function handle(): int
|
||||
}
|
||||
|
||||
$existingTarget = Policy::query()
|
||||
->where('tenant_id', $policy->tenant_id)
|
||||
->where('managed_environment_id', $policy->managed_environment_id)
|
||||
->where('external_id', $policy->external_id)
|
||||
->where('policy_type', 'windowsEnrollmentStatusPage')
|
||||
->first();
|
||||
|
||||
if ($existingTarget) {
|
||||
$policy->forceFill(['ignored_at' => now()])->save();
|
||||
$ignoredPolicies++;
|
||||
$policy->forceFill(['missing_from_provider_at' => now()])->save();
|
||||
$providerMissingPolicies++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policy->forceFill([
|
||||
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||
'missing_from_provider_at' => null,
|
||||
])->save();
|
||||
$changedPolicies++;
|
||||
|
||||
@ -106,7 +107,7 @@ public function handle(): int
|
||||
$this->info('Done.');
|
||||
$this->info('PolicyVersions changed: '.$changedVersions);
|
||||
$this->info('Policies changed: '.$changedPolicies);
|
||||
$this->info('Policies ignored: '.$ignoredPolicies);
|
||||
$this->info('Policies marked provider-missing: '.$providerMissingPolicies);
|
||||
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
@ -129,7 +130,7 @@ private function fetchSnapshotOrNull(Policy $policy): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
|
||||
|
||||
$response = $this->graphClient->getPolicy('enrollmentRestriction', $policy->external_id, [
|
||||
'tenant' => $tenantIdentifier,
|
||||
@ -147,7 +148,7 @@ private function fetchSnapshotOrNull(Policy $policy): ?array
|
||||
return is_array($payload) ? $payload : null;
|
||||
}
|
||||
|
||||
private function resolveTenantOrNull(): ?Tenant
|
||||
private function resolveTenantOrNull(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantOption = $this->option('tenant');
|
||||
|
||||
@ -155,7 +156,7 @@ private function resolveTenantOrNull(): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->forTenant($tenantOption)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
@ -4,11 +4,13 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Models\Workspace;
|
||||
@ -42,7 +44,7 @@ public function handle(): int
|
||||
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
||||
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
||||
$scenarioConfig = is_array($fixture['blocked_drillthrough'] ?? null) ? $fixture['blocked_drillthrough'] : [];
|
||||
$tenantRouteKey = (string) ($scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'] ?? '18000000-0000-4000-8000-000000000180');
|
||||
$tenantRouteKey = (string) ($scenarioConfig['managed_environment_id'] ?? $scenarioConfig['tenant_external_id'] ?? '18000000-0000-4000-8000-000000000180');
|
||||
|
||||
$workspace = Workspace::query()->updateOrCreate(
|
||||
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
|
||||
@ -60,16 +62,13 @@ public function handle(): int
|
||||
],
|
||||
);
|
||||
|
||||
$tenant = Tenant::query()->updateOrCreate(
|
||||
['external_id' => $tenantRouteKey],
|
||||
$tenant = ManagedEnvironment::query()->updateOrCreate(
|
||||
['slug' => $tenantRouteKey],
|
||||
[
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
||||
'tenant_id' => $tenantRouteKey,
|
||||
'app_certificate_thumbprint' => null,
|
||||
'app_notes' => null,
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
'environment' => 'dev',
|
||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup ManagedEnvironment'),
|
||||
'lifecycle_status' => ManagedEnvironment::STATUS_ACTIVE,
|
||||
'kind' => 'dev',
|
||||
'is_current' => false,
|
||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||
'rbac_status' => 'ok',
|
||||
@ -82,8 +81,8 @@ public function handle(): int
|
||||
['role' => 'owner'],
|
||||
);
|
||||
|
||||
TenantMembership::query()->updateOrCreate(
|
||||
['tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
ManagedEnvironmentMembership::query()->updateOrCreate(
|
||||
['managed_environment_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-180-browser-smoke'],
|
||||
);
|
||||
|
||||
@ -91,16 +90,16 @@ public function handle(): int
|
||||
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_tenant_preferences')) {
|
||||
if (Schema::hasTable('user_managed_environment_preferences')) {
|
||||
UserTenantPreference::query()->updateOrCreate(
|
||||
['user_id' => (int) $user->getKey(), 'tenant_id' => (int) $tenant->getKey()],
|
||||
['user_id' => (int) $user->getKey(), 'managed_environment_id' => (int) $tenant->getKey()],
|
||||
['last_used_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
$policy = Policy::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||
],
|
||||
@ -113,7 +112,7 @@ public function handle(): int
|
||||
);
|
||||
|
||||
$backupSet = BackupSet::withTrashed()->firstOrNew([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => (string) ($scenarioConfig['backup_set_name'] ?? 'Spec 180 Blocked Stale Backup'),
|
||||
]);
|
||||
|
||||
@ -137,7 +136,7 @@ public function handle(): int
|
||||
]);
|
||||
|
||||
$backupItem->forceFill([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'platform' => 'windows',
|
||||
'captured_at' => $backupSet->completed_at,
|
||||
@ -173,11 +172,11 @@ public function handle(): int
|
||||
['Workspace', (string) $workspace->name],
|
||||
['User email', (string) $user->email],
|
||||
['User password', $password],
|
||||
['Tenant', (string) $tenant->name],
|
||||
['Tenant external id', (string) $tenant->external_id],
|
||||
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
|
||||
['ManagedEnvironment', (string) $tenant->name],
|
||||
['ManagedEnvironment external id', (string) $tenant->external_id],
|
||||
['Dashboard URL', TenantDashboard::getUrl(tenant: $tenant)],
|
||||
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
|
||||
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
|
||||
['Blocked route', BackupSetResource::getUrl(panel: 'admin', tenant: $tenant)],
|
||||
['Locally denied capability', 'tenant.view'],
|
||||
],
|
||||
);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncPolicies extends Command
|
||||
@ -24,16 +24,16 @@ public function handle(): int
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveTenant(): Tenant
|
||||
private function resolveTenant(): ManagedEnvironment
|
||||
{
|
||||
$tenantId = $this->option('tenant');
|
||||
|
||||
if ($tenantId) {
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->forTenant($tenantId)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
return Tenant::currentOrFail();
|
||||
return ManagedEnvironment::currentOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,21 +258,21 @@ private function collectTableStats(array $tables): array
|
||||
$missing = (int) DB::table($table)->whereNull('workspace_id')->count();
|
||||
|
||||
$unresolvableQuery = DB::table($table)
|
||||
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||
->leftJoin('managed_environments', 'managed_environments.id', '=', sprintf('%s.managed_environment_id', $table))
|
||||
->whereNull(sprintf('%s.workspace_id', $table))
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('tenants.id')
|
||||
->orWhereNull('tenants.workspace_id');
|
||||
$query->whereNull('managed_environments.id')
|
||||
->orWhereNull('managed_environments.workspace_id');
|
||||
});
|
||||
|
||||
$unresolvable = (int) $unresolvableQuery->count();
|
||||
|
||||
$sampleIds = DB::table($table)
|
||||
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||
->leftJoin('managed_environments', 'managed_environments.id', '=', sprintf('%s.managed_environment_id', $table))
|
||||
->whereNull(sprintf('%s.workspace_id', $table))
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('tenants.id')
|
||||
->orWhereNull('tenants.workspace_id');
|
||||
$query->whereNull('managed_environments.id')
|
||||
->orWhereNull('managed_environments.workspace_id');
|
||||
})
|
||||
->orderBy(sprintf('%s.id', $table))
|
||||
->limit(5)
|
||||
@ -302,11 +302,11 @@ private function collectWorkspaceWorkloads(array $tables, ?int $maxRows): array
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$rows = DB::table($table)
|
||||
->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||
->join('managed_environments', 'managed_environments.id', '=', sprintf('%s.managed_environment_id', $table))
|
||||
->whereNull(sprintf('%s.workspace_id', $table))
|
||||
->whereNotNull('tenants.workspace_id')
|
||||
->selectRaw('tenants.workspace_id as workspace_id, COUNT(*) as row_count')
|
||||
->groupBy('tenants.workspace_id')
|
||||
->whereNotNull('managed_environments.workspace_id')
|
||||
->selectRaw('managed_environments.workspace_id as workspace_id, COUNT(*) as row_count')
|
||||
->groupBy('managed_environments.workspace_id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
class TenantpilotDispatchBackupSchedules extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:schedules:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||
protected $signature = 'tenantpilot:schedules:dispatch {--tenant=* : Limit to managed_environment_id/external_id}';
|
||||
|
||||
protected $description = 'Dispatch due backup schedules (idempotent per schedule minute-slot).';
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
class TenantpilotDispatchDirectoryGroupsSync extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:directory-groups:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||
protected $signature = 'tenantpilot:directory-groups:dispatch {--tenant=* : Limit to managed_environment_id/external_id}';
|
||||
|
||||
protected $description = 'Dispatch scheduled directory group sync runs (idempotent per tenant minute-slot).';
|
||||
|
||||
@ -96,7 +96,7 @@ public function handle(): int
|
||||
*/
|
||||
private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection
|
||||
{
|
||||
$query = Tenant::activeQuery();
|
||||
$query = ManagedEnvironment::activeQuery();
|
||||
|
||||
if ($tenantIdentifiers !== []) {
|
||||
$query->where(function ($subQuery) use ($tenantIdentifiers) {
|
||||
@ -107,8 +107,7 @@ private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\C
|
||||
continue;
|
||||
}
|
||||
|
||||
$subQuery->orWhere('tenant_id', $identifier)
|
||||
->orWhere('external_id', $identifier);
|
||||
$subQuery->orWhere('slug', $identifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
@ -26,7 +26,7 @@ class TenantpilotPurgeNonPersistentData extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:purge-nonpersistent
|
||||
{tenant? : Tenant id / tenant_id / external_id (defaults to current tenant)}
|
||||
{tenant? : ManagedEnvironment id / managed_environment_id / external_id (defaults to current tenant)}
|
||||
{--all : Purge for all tenants}
|
||||
{--force : Actually delete rows}';
|
||||
|
||||
@ -68,7 +68,7 @@ public function handle(): int
|
||||
$counts = $this->countsForTenant($tenant);
|
||||
|
||||
$this->line('');
|
||||
$this->info("Tenant: {$tenant->id} ({$tenant->name})");
|
||||
$this->info("ManagedEnvironment: {$tenant->id} ({$tenant->name})");
|
||||
$this->table(
|
||||
['Table', 'Rows'],
|
||||
collect($counts)
|
||||
@ -83,31 +83,31 @@ public function handle(): int
|
||||
|
||||
DB::transaction(function () use ($tenant): void {
|
||||
BackupSchedule::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
RestoreRun::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
BackupItem::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
BackupSet::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->delete();
|
||||
});
|
||||
|
||||
@ -122,19 +122,19 @@ public function handle(): int
|
||||
private function resolveTenants()
|
||||
{
|
||||
if ((bool) $this->option('all')) {
|
||||
return Tenant::query()->get();
|
||||
return ManagedEnvironment::query()->get();
|
||||
}
|
||||
|
||||
$tenantArg = $this->argument('tenant');
|
||||
|
||||
if ($tenantArg !== null && $tenantArg !== '') {
|
||||
$tenant = Tenant::query()->forTenant($tenantArg)->first();
|
||||
$tenant = ManagedEnvironment::query()->forTenant($tenantArg)->first();
|
||||
|
||||
return $tenant ? collect([$tenant]) : collect();
|
||||
}
|
||||
|
||||
try {
|
||||
return collect([Tenant::currentOrFail()]);
|
||||
return collect([ManagedEnvironment::currentOrFail()]);
|
||||
} catch (RuntimeException) {
|
||||
return collect();
|
||||
}
|
||||
@ -143,30 +143,30 @@ private function resolveTenants()
|
||||
/**
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function countsForTenant(Tenant $tenant): array
|
||||
private function countsForTenant(ManagedEnvironment $tenant): array
|
||||
{
|
||||
return [
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::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(),
|
||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_schedules' => BackupSchedule::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||
'audit_logs_retained' => AuditLog::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||
'restore_runs' => RestoreRun::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||
'backup_items' => BackupItem::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||
'backup_sets' => BackupSet::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||
'policy_versions' => PolicyVersion::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||
'policies' => Policy::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
private function recordPurgeOperationRun(ManagedEnvironment $tenant, array $counts): void
|
||||
{
|
||||
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
|
||||
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'managed_environment_id' => (int) $tenant->id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => OperationRunType::BackupSchedulePurge->value,
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\OperationCatalog;
|
||||
@ -15,7 +15,7 @@
|
||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
|
||||
{--tenant=* : Limit to tenant_id/external_id}
|
||||
{--tenant=* : Limit to managed_environment_id/external_id}
|
||||
{--older-than=5 : Only reconcile runs older than N minutes}
|
||||
{--dry-run : Do not write changes}';
|
||||
|
||||
@ -46,7 +46,7 @@ public function handle(
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$query->whereIn('tenant_id', $tenantIds);
|
||||
$query->whereIn('managed_environment_id', $tenantIds);
|
||||
}
|
||||
|
||||
$reconciled = 0;
|
||||
@ -78,7 +78,7 @@ public function handle(
|
||||
|
||||
$schedule = BackupSchedule::query()
|
||||
->whereKey((int) $backupScheduleId)
|
||||
->where('tenant_id', (int) $operationRun->tenant_id)
|
||||
->where('managed_environment_id', (int) $operationRun->managed_environment_id)
|
||||
->first();
|
||||
|
||||
if (! $schedule instanceof BackupSchedule) {
|
||||
@ -135,7 +135,7 @@ private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->forTenant($identifier)
|
||||
->first();
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
@ -13,7 +13,7 @@ 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}
|
||||
{--tenant=* : Limit reconciliation to managed_environment_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}';
|
||||
@ -93,9 +93,9 @@ private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||
$tenant = ManagedEnvironment::query()->forTenant($identifier)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ public function handle(PolicySnapshotService $snapshotService): int
|
||||
|
||||
// Create PolicyVersion to save the snapshot
|
||||
$policy->versions()->create([
|
||||
'tenant_id' => $policy->tenant_id,
|
||||
'managed_environment_id' => $policy->managed_environment_id,
|
||||
'version_number' => $policy->versions()->max('version_number') + 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Contracts\Hardening;
|
||||
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
|
||||
interface WriteGateInterface
|
||||
{
|
||||
@ -12,12 +12,12 @@ interface WriteGateInterface
|
||||
*
|
||||
* @throws ProviderAccessHardeningRequired when the operation is blocked
|
||||
*/
|
||||
public function evaluate(Tenant $tenant, string $operationType): void;
|
||||
public function evaluate(ManagedEnvironment $tenant, string $operationType): void;
|
||||
|
||||
/**
|
||||
* Check whether the gate would block a write operation for the given tenant.
|
||||
*
|
||||
* Non-throwing variant for UI disabled-state checks.
|
||||
*/
|
||||
public function wouldBlock(Tenant $tenant): bool;
|
||||
public function wouldBlock(ManagedEnvironment $tenant): bool;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -23,7 +23,7 @@ protected static function tenantOwnedRelationshipName(): string
|
||||
: 'tenant';
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
|
||||
protected static function resolveTenantContextForTenantOwnedRecords(): ?ManagedEnvironment
|
||||
{
|
||||
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
@ -41,7 +41,7 @@ public static function getTenantOwnedEloquentQuery(): Builder
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
|
||||
}
|
||||
|
||||
protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant = null): Builder
|
||||
protected static function scopeTenantOwnedQuery(Builder $query, ?ManagedEnvironment $tenant = null): Builder
|
||||
{
|
||||
return app(TenantOwnedQueryScope::class)->apply(
|
||||
$query,
|
||||
@ -50,7 +50,7 @@ protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant
|
||||
);
|
||||
}
|
||||
|
||||
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model
|
||||
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?ManagedEnvironment $tenant = null): ?Model
|
||||
{
|
||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||
$query ?? parent::getEloquentQuery(),
|
||||
@ -60,7 +60,7 @@ protected static function resolveTenantOwnedRecord(Model|int|string|null $record
|
||||
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record);
|
||||
}
|
||||
|
||||
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model
|
||||
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?ManagedEnvironment $tenant = null): Model
|
||||
{
|
||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||
$query ?? parent::getEloquentQuery(),
|
||||
|
||||
@ -4,50 +4,50 @@
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use RuntimeException;
|
||||
|
||||
trait ResolvesPanelTenantContext
|
||||
{
|
||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||
protected static function resolveTenantContextForCurrentPanel(): ?ManagedEnvironment
|
||||
{
|
||||
$request = request();
|
||||
|
||||
if (static::currentPanelId($request) === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = ManagedEnvironment::current();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
|
||||
public static function panelTenantContext(): ?Tenant
|
||||
public static function panelTenantContext(): ?ManagedEnvironment
|
||||
{
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
public static function trustedPanelTenantContext(): ?Tenant
|
||||
public static function trustedPanelTenantContext(): ?ManagedEnvironment
|
||||
{
|
||||
return static::panelTenantContext();
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
||||
protected static function resolveTenantContextForCurrentPanelOrFail(): ManagedEnvironment
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
throw new RuntimeException('No tenant context selected.');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
||||
protected static function resolveTrustedPanelTenantContextOrFail(): ManagedEnvironment
|
||||
{
|
||||
return static::resolveTenantContextForCurrentPanelOrFail();
|
||||
}
|
||||
@ -65,10 +65,6 @@ private static function currentPanelId(mixed $request): ?string
|
||||
: null;
|
||||
|
||||
if (is_string($routeName) && $routeName !== '') {
|
||||
if (str_contains($routeName, '.tenant.')) {
|
||||
return 'tenant';
|
||||
}
|
||||
|
||||
if (str_contains($routeName, '.admin.')) {
|
||||
return 'admin';
|
||||
}
|
||||
@ -78,10 +74,6 @@ private static function currentPanelId(mixed $request): ?string
|
||||
? '/'.ltrim((string) $request->path(), '/')
|
||||
: null;
|
||||
|
||||
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
|
||||
return 'tenant';
|
||||
}
|
||||
|
||||
if (is_string($path) && str_starts_with($path, '/admin/')) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||
use Filament\Facades\Filament;
|
||||
@ -54,7 +54,7 @@ protected static function resolveGlobalSearchTenant(): ?Model
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Workspace;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait WorkspaceScopedTenantRoutes
|
||||
{
|
||||
public static function getSlug(?Panel $panel = null): string
|
||||
{
|
||||
return static::workspaceScopedSlug(parent::getSlug($panel), $panel);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||
|
||||
if ($panelId !== 'admin') {
|
||||
return parent::getUrl($name, $parameters, $isAbsolute, $panelId, $tenant, $shouldGuessMissingParameters);
|
||||
}
|
||||
|
||||
$resolvedTenant = static::resolveWorkspaceScopedTenant($parameters, $tenant);
|
||||
|
||||
if (! $resolvedTenant instanceof ManagedEnvironment) {
|
||||
return url('/admin');
|
||||
}
|
||||
|
||||
$workspace = static::resolveWorkspaceScopedWorkspace($resolvedTenant, $parameters);
|
||||
|
||||
if (! $workspace instanceof Workspace && ! is_string($workspace) && ! is_int($workspace)) {
|
||||
return url('/admin');
|
||||
}
|
||||
|
||||
$parameters['tenant'] ??= $resolvedTenant;
|
||||
$parameters['workspace'] ??= $workspace;
|
||||
|
||||
return parent::getUrl($name, $parameters, $isAbsolute, $panelId, null, $shouldGuessMissingParameters);
|
||||
}
|
||||
|
||||
protected static function workspaceScopedSlug(string $slug, ?Panel $panel = null): string
|
||||
{
|
||||
if (! static::shouldUseWorkspaceScopedTenantRoutes($panel)) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$prefix = 'workspaces/{workspace}/environments/{tenant}/';
|
||||
|
||||
return str_starts_with($slug, $prefix)
|
||||
? $slug
|
||||
: $prefix.ltrim($slug, '/');
|
||||
}
|
||||
|
||||
protected static function shouldUseWorkspaceScopedTenantRoutes(?Panel $panel = null): bool
|
||||
{
|
||||
$panelId = $panel?->getId() ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||
|
||||
return $panelId === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected static function resolveWorkspaceScopedTenant(array $parameters, ?Model $tenant = null): ?ManagedEnvironment
|
||||
{
|
||||
$parameterTenant = $parameters['tenant'] ?? $parameters['environment'] ?? null;
|
||||
|
||||
if ($parameterTenant instanceof ManagedEnvironment) {
|
||||
return $parameterTenant;
|
||||
}
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$record = $parameters['record'] ?? null;
|
||||
|
||||
if ($record instanceof Model) {
|
||||
$relationshipName = static::workspaceScopedTenantRelationshipName();
|
||||
|
||||
if (method_exists($record, $relationshipName)) {
|
||||
$recordTenant = $record->getRelationValue($relationshipName);
|
||||
|
||||
if (! $recordTenant instanceof ManagedEnvironment) {
|
||||
$recordTenant = $record->{$relationshipName}()->first();
|
||||
}
|
||||
|
||||
if ($recordTenant instanceof ManagedEnvironment) {
|
||||
return $recordTenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
||||
$resolvedTenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($resolvedTenant instanceof ManagedEnvironment) {
|
||||
return $resolvedTenant;
|
||||
}
|
||||
}
|
||||
|
||||
if (method_exists(static::class, 'panelTenantContext')) {
|
||||
$resolvedTenant = static::panelTenantContext();
|
||||
|
||||
if ($resolvedTenant instanceof ManagedEnvironment) {
|
||||
return $resolvedTenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected static function resolveWorkspaceScopedWorkspace(ManagedEnvironment $tenant, array $parameters): Workspace|string|int|null
|
||||
{
|
||||
$workspace = $parameters['workspace'] ?? null;
|
||||
|
||||
if ($workspace instanceof Workspace || is_string($workspace) || is_int($workspace)) {
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
$tenantWorkspace = $tenant->workspace;
|
||||
|
||||
if ($tenantWorkspace instanceof Workspace) {
|
||||
return $tenantWorkspace;
|
||||
}
|
||||
|
||||
return $tenant->workspace()->first();
|
||||
}
|
||||
|
||||
protected static function workspaceScopedTenantRelationshipName(): string
|
||||
{
|
||||
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
|
||||
? static::$tenantOwnershipRelationshipName
|
||||
: null;
|
||||
|
||||
return is_string($relationshipName) && $relationshipName !== ''
|
||||
? $relationshipName
|
||||
: 'tenant';
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
@ -185,7 +185,7 @@ public static function canAccess(): bool
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -217,7 +217,7 @@ public function refreshStats(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
$aggregate = $tenant instanceof Tenant
|
||||
$aggregate = $tenant instanceof ManagedEnvironment
|
||||
? $this->governanceAggregate($tenant, $stats)
|
||||
: null;
|
||||
|
||||
@ -442,7 +442,7 @@ private function compareNowAction(): Action
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
|
||||
|
||||
return;
|
||||
@ -509,7 +509,7 @@ public function getFindingsUrl(): ?string
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -524,7 +524,7 @@ public function getRunUrl(): ?string
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -551,7 +551,7 @@ public function openCompareMatrixUrl(): ?string
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
||||
private function governanceAggregate(ManagedEnvironment $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||
@ -575,7 +575,7 @@ private function resolveCompareMatrixProfile(): ?BaselineProfile
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -283,7 +283,7 @@ public function form(Schema $schema): Schema
|
||||
])
|
||||
->schema([
|
||||
Select::make('draftTenantSort')
|
||||
->label('Tenant sort')
|
||||
->label('ManagedEnvironment sort')
|
||||
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
||||
->default('tenant_name')
|
||||
->native(false)
|
||||
@ -441,13 +441,12 @@ public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?st
|
||||
{
|
||||
$tenant = $this->tenant($tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BaselineCompareLanding::getUrl(
|
||||
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
|
||||
panel: 'tenant',
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
@ -456,7 +455,7 @@ public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey =
|
||||
{
|
||||
$tenant = $this->tenant($tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -573,7 +572,7 @@ public function stagedFilterSummary(): array
|
||||
}
|
||||
|
||||
if ($this->draftTenantSort !== $this->tenantSort) {
|
||||
$summary['Tenant sort'] = $this->draftTenantSort;
|
||||
$summary['ManagedEnvironment sort'] = $this->draftTenantSort;
|
||||
}
|
||||
|
||||
if ($this->draftSubjectSort !== $this->subjectSort) {
|
||||
@ -855,7 +854,7 @@ private function routeParameters(array $overrides = []): array
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
||||
}
|
||||
|
||||
private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
|
||||
private function navigationContext(?ManagedEnvironment $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
@ -870,9 +869,9 @@ private function navigationContext(?Tenant $tenant = null, ?string $subjectKey =
|
||||
);
|
||||
}
|
||||
|
||||
private function tenant(int $tenantId): ?Tenant
|
||||
private function tenant(int $tenantId): ?ManagedEnvironment
|
||||
{
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->whereKey($tenantId)
|
||||
->where('workspace_id', (int) $this->getRecord()->workspace_id)
|
||||
->first();
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
@ -43,14 +43,14 @@ protected function getLayoutData(): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
* @return Collection<int, ManagedEnvironment>
|
||||
*/
|
||||
public function getTenants(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
return ManagedEnvironment::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
||||
@ -75,9 +75,9 @@ public function selectTenant(int $tenantId): void
|
||||
$tenant = null;
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
$tenant = ManagedEnvironment::query()->whereKey($tenantId)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
||||
@ -93,14 +93,14 @@ public function selectTenant(int $tenantId): void
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
$tenant = Tenant::query()
|
||||
if (! $tenant instanceof ManagedEnvironment || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -126,15 +126,15 @@ public function selectTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
||||
public function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation
|
||||
{
|
||||
return TenantLifecyclePresentation::fromTenant($tenant);
|
||||
}
|
||||
|
||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
private function persistLastTenant(User $user, ManagedEnvironment $tenant): void
|
||||
{
|
||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
||||
@ -142,12 +142,12 @@ private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('user_tenant_preferences')) {
|
||||
if (! Schema::hasTable('user_managed_environment_preferences')) {
|
||||
return;
|
||||
}
|
||||
|
||||
UserTenantPreference::query()->updateOrCreate(
|
||||
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
|
||||
['user_id' => $user->getKey(), 'managed_environment_id' => $tenant->getKey()],
|
||||
['last_used_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,8 +63,10 @@ public function getWorkspaces(): Collection
|
||||
->where('user_id', $user->getKey());
|
||||
})
|
||||
->whereNull('archived_at')
|
||||
->whereNull('closed_at')
|
||||
->withCount(['tenants' => function ($query): void {
|
||||
$query->where('status', 'active');
|
||||
$query->where('lifecycle_status', 'active')
|
||||
->whereNull('removed_from_workspace_at');
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@ -94,7 +96,7 @@ public function selectWorkspace(int $workspaceId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! empty($workspace->archived_at)) {
|
||||
if (! $workspace->isSelectableAsContext()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@ -6,14 +6,19 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||
@ -23,13 +28,16 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use DomainException;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
@ -52,7 +60,7 @@ class CrossTenantComparePage extends Page implements HasForms
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $title = 'Cross-Tenant Compare';
|
||||
protected static ?string $title = 'Cross-ManagedEnvironment Compare';
|
||||
|
||||
protected static ?string $slug = 'cross-tenant-compare';
|
||||
|
||||
@ -170,7 +178,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
|
||||
if ($sourceTenant instanceof Tenant) {
|
||||
if ($sourceTenant instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make('open_source_tenant')
|
||||
->label('Open source tenant')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
@ -180,7 +188,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
if ($targetTenant instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make('open_target_tenant')
|
||||
->label('Open target tenant')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
@ -192,6 +200,7 @@ protected function getHeaderActions(): array
|
||||
->label('Generate promotion preflight')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => ! is_array($this->preflight))
|
||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||
@ -201,6 +210,7 @@ protected function getHeaderActions(): array
|
||||
fn (): ?Workspace => $this->workspace(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveVisibility()
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||
->apply()
|
||||
@ -223,6 +233,19 @@ protected function getHeaderActions(): array
|
||||
|
||||
$actions[] = $preflightAction;
|
||||
|
||||
$actions[] = Action::make('executePromotion')
|
||||
->label('Execute promotion')
|
||||
->icon('heroicon-o-play')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => is_array($this->preflight))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Execute promotion')
|
||||
->modalDescription(fn (): string => $this->executePromotionConfirmationDescription())
|
||||
->modalSubmitActionLabel('Queue promotion')
|
||||
->disabled(fn (): bool => $this->executePromotionDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->executePromotionDisabledReason())
|
||||
->action(fn (): mixed => $this->executePromotion());
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
@ -282,6 +305,74 @@ public function generatePromotionPreflight(): void
|
||||
}
|
||||
}
|
||||
|
||||
public function executePromotion(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->authorizePromotionExecution();
|
||||
|
||||
if (! is_array($this->preview) || ! is_array($this->preflight)) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Generate a current promotion preflight before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Refresh the compare selection before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = app(CrossTenantPromotionExecutionService::class)->start(
|
||||
selection: $selection,
|
||||
preview: $this->preview,
|
||||
preflight: $this->preflight,
|
||||
actor: $user,
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
} catch (DomainException|InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||
result: $result,
|
||||
blockedTitle: 'Promotion execution blocked',
|
||||
runUrl: OperationRunLinks::tenantlessView($result->run),
|
||||
scopeBusyTitle: 'Promotion scope busy',
|
||||
scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.',
|
||||
);
|
||||
|
||||
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}
|
||||
|
||||
public function clearSelectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters([
|
||||
@ -297,17 +388,17 @@ public function selectionUrl(): string
|
||||
}
|
||||
|
||||
public static function launchUrl(
|
||||
?Tenant $sourceTenant = null,
|
||||
?Tenant $targetTenant = null,
|
||||
?ManagedEnvironment $sourceTenant = null,
|
||||
?ManagedEnvironment $targetTenant = null,
|
||||
?CanonicalNavigationContext $navigationContext = null,
|
||||
): string {
|
||||
$parameters = [];
|
||||
|
||||
if ($sourceTenant instanceof Tenant) {
|
||||
if ($sourceTenant instanceof ManagedEnvironment) {
|
||||
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
|
||||
}
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
if ($targetTenant instanceof ManagedEnvironment) {
|
||||
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
|
||||
}
|
||||
|
||||
@ -351,7 +442,7 @@ public function sourceTenantUrl(): ?string
|
||||
{
|
||||
$tenant = $this->selectedSourceTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -362,7 +453,7 @@ public function targetTenantUrl(): ?string
|
||||
{
|
||||
$tenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -453,12 +544,36 @@ private function authorizePreflightExecution(): void
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizePromotionExecution(): void
|
||||
{
|
||||
$this->authorizePreflightExecution();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $targetTenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compareSelection(): ?CrossTenantCompareSelection
|
||||
{
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
|
||||
if (! $sourceTenant instanceof ManagedEnvironment || ! $targetTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -475,7 +590,7 @@ private function compareSelection(): ?CrossTenantCompareSelection
|
||||
);
|
||||
}
|
||||
|
||||
private function selectedSourceTenant(): ?Tenant
|
||||
private function selectedSourceTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if ($this->sourceTenantId === null) {
|
||||
return null;
|
||||
@ -484,7 +599,7 @@ private function selectedSourceTenant(): ?Tenant
|
||||
return $this->resolveAuthorizedTenant($this->sourceTenantId);
|
||||
}
|
||||
|
||||
private function selectedTargetTenant(): ?Tenant
|
||||
private function selectedTargetTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if ($this->targetTenantId === null) {
|
||||
return null;
|
||||
@ -493,7 +608,7 @@ private function selectedTargetTenant(): ?Tenant
|
||||
return $this->resolveAuthorizedTenant($this->targetTenantId);
|
||||
}
|
||||
|
||||
private function resolveAuthorizedTenant(string $tenantId): Tenant
|
||||
private function resolveAuthorizedTenant(string $tenantId): ManagedEnvironment
|
||||
{
|
||||
$workspace = $this->workspace();
|
||||
$user = auth()->user();
|
||||
@ -502,12 +617,12 @@ private function resolveAuthorizedTenant(string $tenantId): Tenant
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereKey((int) $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -537,16 +652,16 @@ private function tenantOptions(): array
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->select('tenants.*')
|
||||
->orderBy('tenants.name')
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->select('managed_environments.*')
|
||||
->orderBy('managed_environments.name')
|
||||
->get();
|
||||
|
||||
$resolver->primeMemberships($user, $tenants->modelKeys());
|
||||
|
||||
return $tenants
|
||||
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||
->mapWithKeys(fn (Tenant $tenant): array => [
|
||||
->filter(fn (ManagedEnvironment $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||
->mapWithKeys(fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => (string) $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -564,7 +679,7 @@ private function policyTypeOptions(): array
|
||||
}
|
||||
|
||||
return InventoryItem::query()
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->whereIn('managed_environment_id', $tenantIds)
|
||||
->whereNotNull('policy_type')
|
||||
->where('policy_type', '!=', '')
|
||||
->distinct()
|
||||
@ -593,6 +708,73 @@ private function preflightDisabledReason(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionDisabledReason(): ?string
|
||||
{
|
||||
if ($this->selectionMessage !== null) {
|
||||
return $this->selectionMessage;
|
||||
}
|
||||
|
||||
if (! is_array($this->preview)) {
|
||||
return 'Run compare preview before executing promotion.';
|
||||
}
|
||||
|
||||
if (! is_array($this->preflight)) {
|
||||
return 'Generate a current promotion preflight before executing promotion.';
|
||||
}
|
||||
|
||||
if ((int) data_get($this->preflight, 'summary.ready', 0) <= 0) {
|
||||
return 'Current promotion preflight has no ready governed subjects to execute.';
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if ($workspaceResolver->isMember($user, $workspace)
|
||||
&& ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return 'You need workspace baseline manage access to execute promotion.';
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if ($targetTenant instanceof ManagedEnvironment) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You need target tenant manage access to execute promotion.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionConfirmationDescription(): string
|
||||
{
|
||||
$selection = $this->compareSelection();
|
||||
$ready = (int) data_get($this->preflight, 'summary.ready', 0);
|
||||
$blocked = (int) data_get($this->preflight, 'summary.blocked', 0);
|
||||
$manualMappingRequired = (int) data_get($this->preflight, 'summary.manual_mapping_required', 0);
|
||||
$excluded = $blocked + $manualMappingRequired;
|
||||
|
||||
$sourceTenantName = $selection?->sourceTenant->name ?? 'Source tenant';
|
||||
$targetTenantName = $selection?->targetTenant->name ?? 'Target tenant';
|
||||
|
||||
return sprintf(
|
||||
'Queue one promotion run from %s to %s for %d ready governed subject%s. %d subject%s remain excluded on the compare page.',
|
||||
$sourceTenantName,
|
||||
$targetTenantName,
|
||||
$ready,
|
||||
$ready === 1 ? '' : 's',
|
||||
$excluded,
|
||||
$excluded === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -54,7 +54,7 @@ class FindingsHygieneReport extends Page implements HasTable
|
||||
protected string $view = 'filament.pages.findings.findings-hygiene-report';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $visibleTenants = null;
|
||||
|
||||
@ -109,7 +109,7 @@ public function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant'),
|
||||
->label('ManagedEnvironment'),
|
||||
TextColumn::make('subject_display_name')
|
||||
->label('Finding')
|
||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
||||
@ -138,8 +138,8 @@ public function table(Table $table): Table
|
||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($this->hygieneService()->lastWorkflowActivityAt($record))),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
])
|
||||
@ -183,10 +183,10 @@ public function availableFilters(): array
|
||||
],
|
||||
[
|
||||
'key' => 'tenant',
|
||||
'label' => 'Tenant',
|
||||
'label' => 'ManagedEnvironment',
|
||||
'fixed' => false,
|
||||
'options' => collect($this->visibleTenants())
|
||||
->map(fn (Tenant $tenant): array => [
|
||||
->map(fn (ManagedEnvironment $tenant): array => [
|
||||
'value' => (string) $tenant->getKey(),
|
||||
'label' => (string) $tenant->name,
|
||||
])
|
||||
@ -290,12 +290,12 @@ public function updatedTableFilters(): void
|
||||
|
||||
public function clearTenantFilter(): void
|
||||
{
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
$this->resetTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function visibleTenants(): array
|
||||
{
|
||||
@ -394,7 +394,7 @@ private function filteredIssueQuery(bool $includeTenantFilter = true, ?string $r
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->visibleTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => (string) $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -413,8 +413,8 @@ private function applyRequestedTenantPrefilter(): void
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -422,7 +422,7 @@ private function applyRequestedTenantPrefilter(): void
|
||||
|
||||
private function normalizeTenantFilterState(): void
|
||||
{
|
||||
$configuredTenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
|
||||
$configuredTenantFilter = data_get($this->currentFiltersState(), 'managed_environment_id.value');
|
||||
|
||||
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
||||
return;
|
||||
@ -432,7 +432,7 @@ private function normalizeTenantFilterState(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -450,7 +450,7 @@ private function currentFiltersState(): array
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
|
||||
$tenantFilter = data_get($this->currentFiltersState(), 'managed_environment_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
return null;
|
||||
@ -467,7 +467,7 @@ private function currentTenantFilterId(): ?int
|
||||
return null;
|
||||
}
|
||||
|
||||
private function filteredTenant(): ?Tenant
|
||||
private function filteredTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
@ -484,11 +484,11 @@ private function filteredTenant(): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
private function activeVisibleTenant(): ?Tenant
|
||||
private function activeVisibleTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -505,13 +505,13 @@ private function tenantPrefilterSource(): string
|
||||
{
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$activeTenant = $this->activeVisibleTenant();
|
||||
|
||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
||||
if ($activeTenant instanceof ManagedEnvironment && $activeTenant->is($tenant)) {
|
||||
return 'active_tenant_context';
|
||||
}
|
||||
|
||||
@ -561,11 +561,11 @@ private function findingDetailUrl(Finding $record): string
|
||||
{
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
|
||||
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
|
||||
|
||||
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -62,12 +62,12 @@ class FindingsIntakeQueue extends Page implements HasTable
|
||||
protected string $view = 'filament.pages.findings.findings-intake-queue';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $visibleTenants = null;
|
||||
|
||||
@ -135,7 +135,7 @@ public function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant'),
|
||||
->label('ManagedEnvironment'),
|
||||
TextColumn::make('subject_display_name')
|
||||
->label('Finding')
|
||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
||||
@ -166,8 +166,8 @@ public function table(Table $table): Table
|
||||
->color(fn (Finding $record): string => $this->queueReasonColor($record)),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
])
|
||||
@ -278,12 +278,12 @@ public function updatedTableFilters(): void
|
||||
|
||||
public function clearTenantFilter(): void
|
||||
{
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
$this->resetTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function visibleTenants(): array
|
||||
{
|
||||
@ -301,12 +301,12 @@ public function visibleTenants(): array
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$resolver->primeMemberships(
|
||||
$user,
|
||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
array_map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
);
|
||||
|
||||
return $this->visibleTenants = array_values(array_filter(
|
||||
$tenants,
|
||||
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||
fn (ManagedEnvironment $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||
));
|
||||
}
|
||||
|
||||
@ -335,7 +335,7 @@ private function claimAction(): Action
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
@ -406,7 +406,7 @@ private function authorizePageAccess(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function authorizedTenants(): array
|
||||
{
|
||||
@ -422,10 +422,10 @@ private function authorizedTenants(): array
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->where('tenants.status', 'active')
|
||||
->orderBy('tenants.name')
|
||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
->all();
|
||||
}
|
||||
|
||||
@ -448,7 +448,7 @@ private function queueBaseQuery(): Builder
|
||||
{
|
||||
$workspace = $this->workspace();
|
||||
$tenantIds = array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||
$this->visibleTenants(),
|
||||
);
|
||||
|
||||
@ -460,7 +460,7 @@ private function queueBaseQuery(): Builder
|
||||
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
||||
->withSubjectDisplayName()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->whereNull('assignee_user_id')
|
||||
->whereIn('status', Finding::openStatuses());
|
||||
}
|
||||
@ -479,7 +479,7 @@ private function filteredQueueQuery(
|
||||
$resolvedQueueView = $queueView ?? $this->queueView;
|
||||
|
||||
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterId()) !== null) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
$query->where('managed_environment_id', $tenantId);
|
||||
}
|
||||
|
||||
if ($resolvedQueueView === 'needs_triage') {
|
||||
@ -514,7 +514,7 @@ private function filteredQueueQuery(
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->visibleTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => (string) $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -533,8 +533,8 @@ private function applyRequestedTenantPrefilter(): void
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -542,7 +542,7 @@ private function applyRequestedTenantPrefilter(): void
|
||||
|
||||
private function normalizeTenantFilterState(): void
|
||||
{
|
||||
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
|
||||
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'managed_environment_id.value');
|
||||
|
||||
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
||||
return;
|
||||
@ -552,7 +552,7 @@ private function normalizeTenantFilterState(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -570,7 +570,7 @@ private function currentQueueFiltersState(): array
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
|
||||
$tenantFilter = data_get($this->currentQueueFiltersState(), 'managed_environment_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
return null;
|
||||
@ -587,7 +587,7 @@ private function currentTenantFilterId(): ?int
|
||||
return null;
|
||||
}
|
||||
|
||||
private function filteredTenant(): ?Tenant
|
||||
private function filteredTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
@ -604,11 +604,11 @@ private function filteredTenant(): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
private function activeVisibleTenant(): ?Tenant
|
||||
private function activeVisibleTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -625,13 +625,13 @@ private function tenantPrefilterSource(): string
|
||||
{
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$activeTenant = $this->activeVisibleTenant();
|
||||
|
||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
||||
if ($activeTenant instanceof ManagedEnvironment && $activeTenant->is($tenant)) {
|
||||
return 'active_tenant_context';
|
||||
}
|
||||
|
||||
@ -690,11 +690,11 @@ private function findingDetailUrl(Finding $record): string
|
||||
{
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
|
||||
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
|
||||
|
||||
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -58,12 +58,12 @@ class MyFindingsInbox extends Page implements HasTable
|
||||
protected string $view = 'filament.pages.findings.my-findings-inbox';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $visibleTenants = null;
|
||||
|
||||
@ -127,7 +127,7 @@ public function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant'),
|
||||
->label('ManagedEnvironment'),
|
||||
TextColumn::make('subject_display_name')
|
||||
->label('Finding')
|
||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
||||
@ -153,8 +153,8 @@ public function table(Table $table): Table
|
||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
Filter::make('overdue')
|
||||
@ -207,10 +207,10 @@ public function availableFilters(): array
|
||||
],
|
||||
[
|
||||
'key' => 'tenant',
|
||||
'label' => 'Tenant',
|
||||
'label' => 'ManagedEnvironment',
|
||||
'fixed' => false,
|
||||
'options' => collect($this->visibleTenants())
|
||||
->map(fn (Tenant $tenant): array => [
|
||||
->map(fn (ManagedEnvironment $tenant): array => [
|
||||
'value' => (string) $tenant->getKey(),
|
||||
'label' => (string) $tenant->name,
|
||||
])
|
||||
@ -272,7 +272,7 @@ public function emptyState(): array
|
||||
|
||||
$activeTenant = $this->activeVisibleTenant();
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
if ($activeTenant instanceof ManagedEnvironment) {
|
||||
return [
|
||||
'title' => 'No visible assigned findings right now',
|
||||
'body' => 'Nothing currently assigned to you needs attention in the visible tenant scope. You can still open tenant findings for broader context.',
|
||||
@ -280,7 +280,7 @@ public function emptyState(): array
|
||||
'action_name' => 'open_tenant_findings_empty',
|
||||
'action_label' => 'Open tenant findings',
|
||||
'action_kind' => 'url',
|
||||
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant),
|
||||
'action_url' => FindingResource::getUrl('index', tenant: $activeTenant),
|
||||
];
|
||||
}
|
||||
|
||||
@ -302,12 +302,12 @@ public function updatedTableFilters(): void
|
||||
|
||||
public function clearTenantFilter(): void
|
||||
{
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
$this->resetTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function visibleTenants(): array
|
||||
{
|
||||
@ -325,12 +325,12 @@ public function visibleTenants(): array
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$resolver->primeMemberships(
|
||||
$user,
|
||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
array_map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
);
|
||||
|
||||
return $this->visibleTenants = array_values(array_filter(
|
||||
$tenants,
|
||||
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||
fn (ManagedEnvironment $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||
));
|
||||
}
|
||||
|
||||
@ -355,7 +355,7 @@ private function authorizePageAccess(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function authorizedTenants(): array
|
||||
{
|
||||
@ -371,10 +371,10 @@ private function authorizedTenants(): array
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->where('tenants.status', 'active')
|
||||
->orderBy('tenants.name')
|
||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
->all();
|
||||
}
|
||||
|
||||
@ -398,7 +398,7 @@ private function queueBaseQuery(): Builder
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
$tenantIds = array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||
$this->visibleTenants(),
|
||||
);
|
||||
|
||||
@ -410,7 +410,7 @@ private function queueBaseQuery(): Builder
|
||||
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
||||
->withSubjectDisplayName()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->where('assignee_user_id', (int) $user->getKey())
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->orderByRaw(
|
||||
@ -428,7 +428,7 @@ private function filteredQueueQuery(bool $includeTenantFilter = true): Builder
|
||||
$filters = $this->currentQueueFiltersState();
|
||||
|
||||
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterIdFromFilters($filters)) !== null) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
$query->where('managed_environment_id', $tenantId);
|
||||
}
|
||||
|
||||
if ($this->filterIsActive($filters, 'overdue')) {
|
||||
@ -454,7 +454,7 @@ private function filteredQueueQuery(bool $includeTenantFilter = true): Builder
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->visibleTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => (string) $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -473,8 +473,8 @@ private function applyRequestedTenantPrefilter(): void
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -482,7 +482,7 @@ private function applyRequestedTenantPrefilter(): void
|
||||
|
||||
private function normalizeTenantFilterState(): void
|
||||
{
|
||||
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
|
||||
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'managed_environment_id.value');
|
||||
|
||||
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
||||
return;
|
||||
@ -492,7 +492,7 @@ private function normalizeTenantFilterState(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -518,7 +518,7 @@ private function currentTenantFilterId(): ?int
|
||||
*/
|
||||
private function currentTenantFilterIdFromFilters(array $filters): ?int
|
||||
{
|
||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||
$tenantFilter = data_get($filters, 'managed_environment_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
return null;
|
||||
@ -543,7 +543,7 @@ private function filterIsActive(array $filters, string $name): bool
|
||||
return (bool) data_get($filters, "{$name}.isActive", false);
|
||||
}
|
||||
|
||||
private function filteredTenant(): ?Tenant
|
||||
private function filteredTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
@ -560,11 +560,11 @@ private function filteredTenant(): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
private function activeVisibleTenant(): ?Tenant
|
||||
private function activeVisibleTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -581,13 +581,13 @@ private function tenantPrefilterSource(): string
|
||||
{
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$activeTenant = $this->activeVisibleTenant();
|
||||
|
||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
||||
if ($activeTenant instanceof ManagedEnvironment && $activeTenant->is($tenant)) {
|
||||
return 'active_tenant_context';
|
||||
}
|
||||
|
||||
@ -632,11 +632,11 @@ private function findingDetailUrl(Finding $record): string
|
||||
{
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
|
||||
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
|
||||
|
||||
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
||||
}
|
||||
|
||||
713
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
713
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
@ -0,0 +1,713 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Governance;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
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\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class DecisionRegister extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Decision register';
|
||||
|
||||
protected static ?int $navigationSort = 6;
|
||||
|
||||
protected static ?string $title = 'Decision register';
|
||||
|
||||
protected static ?string $slug = 'governance/decisions';
|
||||
|
||||
protected string $view = 'filament.pages.governance.decision-register';
|
||||
|
||||
/**
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $visibleDecisionTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $registerPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $unfilteredRegisterPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private ?array $rowPayloadByExceptionId = null;
|
||||
|
||||
private ?Workspace $workspace = null;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
public string $registerState = 'open';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep tenant and register-state scope visible without introducing a second mutation surface.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The decision register keeps one dominant row action and avoids a More menu in v1.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The decision register is read-only and intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Filtered empty states stay truthful and provide one path back to the broader register scope.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = static::resolveWorkspaceFromRequest();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (static::hasRequestedTenantPrefilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$visibleTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace);
|
||||
|
||||
if ($visibleTenants === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request()->query('register_state') === 'recently_closed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) (app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||
workspace: $workspace,
|
||||
visibleTenants: $visibleTenants,
|
||||
registerState: 'open',
|
||||
)['counts']['open'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
$this->authorizeWorkspaceMembership();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->registerState = $this->resolveRequestedRegisterState();
|
||||
$this->ensureRegisterIsVisible();
|
||||
}
|
||||
|
||||
public function pageUrl(array $overrides = []): string
|
||||
{
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||
? $overrides['tenant']
|
||||
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
|
||||
$resolvedRegisterState = array_key_exists('register_state', $overrides)
|
||||
? $overrides['register_state']
|
||||
: $this->registerState;
|
||||
|
||||
return static::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'managed_environment_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
||||
'register_state' => is_string($resolvedRegisterState) && $resolvedRegisterState !== 'open' ? $resolvedRegisterState : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
public function appliedScope(): array
|
||||
{
|
||||
return [
|
||||
'workspace_label' => $this->workspace()?->name,
|
||||
'tenant_label' => $this->selectedTenant()?->name,
|
||||
'register_state_label' => $this->registerStateLabel($this->registerState),
|
||||
'visible_count' => $this->registerPayload()['counts'][$this->registerState] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, label: string, count: int}>
|
||||
*/
|
||||
public function availableRegisterStates(): array
|
||||
{
|
||||
$counts = $this->registerPayload()['counts'] ?? ['open' => 0, 'recently_closed' => 0];
|
||||
|
||||
return [
|
||||
[
|
||||
'key' => 'open',
|
||||
'label' => 'Open decisions',
|
||||
'count' => (int) ($counts['open'] ?? 0),
|
||||
],
|
||||
[
|
||||
'key' => 'recently_closed',
|
||||
'label' => 'Recently closed',
|
||||
'count' => (int) ($counts['recently_closed'] ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function hasTenantPrefilter(): bool
|
||||
{
|
||||
return $this->selectedTenant() instanceof ManagedEnvironment;
|
||||
}
|
||||
|
||||
public function isActiveRegisterState(string $registerState): bool
|
||||
{
|
||||
return $this->registerState === $registerState;
|
||||
}
|
||||
|
||||
public function emptyStateHeading(): string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return 'This tenant filter is hiding other visible decision follow-through';
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return 'No recently closed decisions match this filter right now.';
|
||||
}
|
||||
|
||||
return 'No open decisions match this filter right now.';
|
||||
}
|
||||
|
||||
public function emptyStateDescription(): string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return 'The current tenant scope is calm, but other visible tenants in this workspace still have open governance decisions.';
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return 'Switch back to open decisions to continue the current follow-through lane, or widen the tenant scope if you were filtering the register.';
|
||||
}
|
||||
|
||||
return 'Try widening the tenant scope or switch to recently closed decisions if you are checking what was just finished.';
|
||||
}
|
||||
|
||||
public function emptyStateActionLabel(): ?string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return 'Clear tenant filter';
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return 'Open current decisions';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function emptyStateActionUrl(): ?string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return $this->pageUrl(['tenant' => null]);
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return $this->pageUrl(['register_state' => 'open']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query($this->tableQuery())
|
||||
->defaultSort('review_due_at', 'asc')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (FindingException $record): ?string => $this->decisionUrl($record))
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label('ManagedEnvironment')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
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(),
|
||||
TextColumn::make('current_validity_state')
|
||||
->label('Impact')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||
TextColumn::make('owner.name')
|
||||
->label('Owner')
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
TextColumn::make('review_due_at')
|
||||
->label('Review due')
|
||||
->dateTime()
|
||||
->since()
|
||||
->placeholder('—')
|
||||
->tooltip(fn (FindingException $record): ?string => $record->review_due_at?->toDayDateTimeString())
|
||||
->sortable(),
|
||||
TextColumn::make('proof_availability')
|
||||
->label('Proof')
|
||||
->state(function (FindingException $record): string {
|
||||
$referenceCount = (int) data_get($record->evidence_summary ?? [], 'reference_count', 0);
|
||||
|
||||
return $referenceCount > 0
|
||||
? $referenceCount.' evidence linked'
|
||||
: 'No linked proof';
|
||||
})
|
||||
->wrap(),
|
||||
TextColumn::make('next_action_label')
|
||||
->label('Next action')
|
||||
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['next_action_label'] ?? null)
|
||||
->visible(fn (): bool => $this->registerState === 'open')
|
||||
->wrap(),
|
||||
TextColumn::make('closure_reason')
|
||||
->label('Closure reason')
|
||||
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['closure_reason'] ?? null)
|
||||
->placeholder('—')
|
||||
->visible(fn (): bool => $this->registerState === 'recently_closed')
|
||||
->wrap(),
|
||||
])
|
||||
->emptyStateHeading($this->emptyStateHeading())
|
||||
->emptyStateDescription($this->emptyStateDescription())
|
||||
->emptyStateActions($this->emptyStateActions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Tables\Actions\Action>
|
||||
*/
|
||||
private function emptyStateActions(): array
|
||||
{
|
||||
$label = $this->emptyStateActionLabel();
|
||||
$url = $this->emptyStateActionUrl();
|
||||
|
||||
if (! is_string($label) || ! is_string($url)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Action::make('empty_state_scope_action')
|
||||
->label($label)
|
||||
->url($url),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<FindingException>
|
||||
*/
|
||||
private function tableQuery(): Builder
|
||||
{
|
||||
$tenantIds = array_values(array_map(
|
||||
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||
$this->currentScopeTenants(),
|
||||
));
|
||||
|
||||
$query = FindingException::query()
|
||||
->where('workspace_id', (int) $this->workspace()?->getKey())
|
||||
->whereIn('managed_environment_id', $tenantIds)
|
||||
->with(['tenant', 'owner', 'currentDecision']);
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return $query
|
||||
->whereIn('status', [
|
||||
FindingException::STATUS_REJECTED,
|
||||
FindingException::STATUS_REVOKED,
|
||||
FindingException::STATUS_SUPERSEDED,
|
||||
])
|
||||
->whereHas('currentDecision', function (Builder $decisionQuery): void {
|
||||
$decisionQuery->where('decided_at', '>=', now()->startOfDay()->subDays(30));
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->whereNotIn('status', [
|
||||
FindingException::STATUS_REJECTED,
|
||||
FindingException::STATUS_REVOKED,
|
||||
FindingException::STATUS_SUPERSEDED,
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceMembership(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureRegisterIsVisible(): void
|
||||
{
|
||||
if ($this->visibleDecisionTenants() === []) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($this->tenantId !== null || $this->registerState !== 'open') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($this->registerPayload()['counts']['open'] ?? 0) === 0) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private 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 = static::resolveAuthorizedTenantsFor($user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function visibleDecisionTenants(): array
|
||||
{
|
||||
if ($this->visibleDecisionTenants !== null) {
|
||||
return $this->visibleDecisionTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
$tenants = $this->authorizedTenants();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace || $tenants === []) {
|
||||
return $this->visibleDecisionTenants = [];
|
||||
}
|
||||
|
||||
return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants);
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('managed_environment_id', 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->tenantId = (int) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
private function resolveRequestedRegisterState(): string
|
||||
{
|
||||
$registerState = request()->query('register_state');
|
||||
|
||||
if (! is_string($registerState)) {
|
||||
return 'open';
|
||||
}
|
||||
|
||||
return in_array($registerState, ['open', 'recently_closed'], true)
|
||||
? $registerState
|
||||
: 'open';
|
||||
}
|
||||
|
||||
private static function hasRequestedTenantPrefilter(): bool
|
||||
{
|
||||
$requestedTenant = request()->query('managed_environment_id', request()->query('tenant'));
|
||||
|
||||
return is_string($requestedTenant) || is_numeric($requestedTenant);
|
||||
}
|
||||
|
||||
private static function resolveWorkspaceFromRequest(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private static function resolveAuthorizedTenantsFor(User $user, Workspace $workspace): array
|
||||
{
|
||||
return $user->tenants()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ManagedEnvironment>|null $authorizedTenants
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private static function resolveVisibleDecisionTenantsFor(User $user, Workspace $workspace, ?array $authorizedTenants = null): array
|
||||
{
|
||||
$tenants = $authorizedTenants ?? static::resolveAuthorizedTenantsFor($user, $workspace);
|
||||
|
||||
if ($tenants === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$resolver->primeMemberships(
|
||||
$user,
|
||||
array_map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
);
|
||||
|
||||
return array_values(array_filter(
|
||||
$tenants,
|
||||
fn (ManagedEnvironment $tenant): bool => $resolver->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW),
|
||||
));
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
if ($this->workspace instanceof Workspace) {
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
return $this->workspace = static::resolveWorkspaceFromRequest();
|
||||
}
|
||||
|
||||
private function selectedTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if (! is_int($this->tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->visibleDecisionTenants() as $tenant) {
|
||||
if ((int) $tenant->getKey() === $this->tenantId) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function currentScopeTenants(): array
|
||||
{
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
|
||||
if ($selectedTenant instanceof ManagedEnvironment) {
|
||||
return [$selectedTenant];
|
||||
}
|
||||
|
||||
return $this->visibleDecisionTenants();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function registerPayload(): array
|
||||
{
|
||||
if (is_array($this->registerPayload)) {
|
||||
return $this->registerPayload;
|
||||
}
|
||||
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return $this->registerPayload = [
|
||||
'rows' => [],
|
||||
'counts' => ['open' => 0, 'recently_closed' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->registerPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||
workspace: $workspace,
|
||||
visibleTenants: $this->currentScopeTenants(),
|
||||
registerState: $this->registerState,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function unfilteredRegisterPayload(): array
|
||||
{
|
||||
if (is_array($this->unfilteredRegisterPayload)) {
|
||||
return $this->unfilteredRegisterPayload;
|
||||
}
|
||||
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return $this->unfilteredRegisterPayload = [
|
||||
'rows' => [],
|
||||
'counts' => ['open' => 0, 'recently_closed' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->unfilteredRegisterPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||
workspace: $workspace,
|
||||
visibleTenants: $this->visibleDecisionTenants(),
|
||||
registerState: 'open',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rowPayload(FindingException $record): array
|
||||
{
|
||||
if (! is_array($this->rowPayloadByExceptionId)) {
|
||||
$this->rowPayloadByExceptionId = collect($this->registerPayload()['rows'] ?? [])
|
||||
->keyBy('exception_id')
|
||||
->all();
|
||||
}
|
||||
|
||||
return $this->rowPayloadByExceptionId[(int) $record->getKey()] ?? [];
|
||||
}
|
||||
|
||||
private function tenantFilterAloneExcludesRows(): bool
|
||||
{
|
||||
if (! is_int($this->tenantId) || $this->registerState !== 'open') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($this->registerPayload()['rows'] ?? []) !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($this->unfilteredRegisterPayload()['counts']['open'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private function registerStateLabel(string $registerState): string
|
||||
{
|
||||
return match ($registerState) {
|
||||
'recently_closed' => 'Recently closed',
|
||||
default => 'Open decisions',
|
||||
};
|
||||
}
|
||||
|
||||
public function decisionUrl(FindingException $record): ?string
|
||||
{
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $tenant),
|
||||
$this->navigationContext()->toQuery(),
|
||||
);
|
||||
}
|
||||
|
||||
private function navigationContext(): CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::forDecisionRegister(
|
||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||
tenantId: $this->tenantId,
|
||||
backLinkUrl: $this->pageUrl(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function appendQuery(string $url, array $query): string
|
||||
{
|
||||
$queryString = http_build_query($query);
|
||||
|
||||
if ($queryString === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
|
||||
return $url.$separator.$queryString;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -47,17 +47,17 @@ class GovernanceInbox extends Page
|
||||
protected string $view = 'filament.pages.governance.governance-inbox';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $visibleFindingTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $reviewTenants = null;
|
||||
|
||||
@ -113,7 +113,7 @@ public function appliedScope(): array
|
||||
return [
|
||||
'workspace_label' => $this->workspace()?->name,
|
||||
'tenant_label' => $selectedTenant?->name,
|
||||
'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none',
|
||||
'tenant_prefilter_source' => $selectedTenant instanceof ManagedEnvironment ? 'explicit_filter' : 'none',
|
||||
'family_key' => $this->family,
|
||||
'family_label' => $this->family !== null
|
||||
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
|
||||
@ -162,7 +162,7 @@ public function calmEmptyState(): array
|
||||
|
||||
public function hasTenantPrefilter(): bool
|
||||
{
|
||||
return $this->selectedTenant() instanceof Tenant;
|
||||
return $this->selectedTenant() instanceof ManagedEnvironment;
|
||||
}
|
||||
|
||||
public function isActiveFamily(?string $familyKey): bool
|
||||
@ -183,7 +183,7 @@ public function pageUrl(array $overrides = []): string
|
||||
return static::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
||||
'managed_environment_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
||||
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
@ -290,7 +290,7 @@ private function hasVisibleFindingExceptionsFamily(): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function visibleFindingTenants(): array
|
||||
{
|
||||
@ -308,17 +308,17 @@ private function visibleFindingTenants(): array
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$resolver->primeMemberships(
|
||||
$user,
|
||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
array_map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
);
|
||||
|
||||
return $this->visibleFindingTenants = array_values(array_filter(
|
||||
$tenants,
|
||||
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||
fn (ManagedEnvironment $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function reviewTenants(): array
|
||||
{
|
||||
@ -343,7 +343,7 @@ private function reviewTenants(): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function authorizedTenants(): array
|
||||
{
|
||||
@ -359,16 +359,16 @@ private function authorizedTenants(): array
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->where('tenants.status', 'active')
|
||||
->orderBy('tenants.name')
|
||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
|
||||
$requestedTenant = request()->query('managed_environment_id', request()->query('tenant'));
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
@ -490,7 +490,7 @@ private function unfilteredInboxPayload(): array
|
||||
);
|
||||
}
|
||||
|
||||
private function selectedTenant(): ?Tenant
|
||||
private function selectedTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if (! is_int($this->tenantId)) {
|
||||
return null;
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -89,7 +89,7 @@ public static function canAccess(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -509,7 +509,7 @@ public function basisRunSummary(): array
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
|
||||
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -523,7 +523,7 @@ public function basisRunSummary(): array
|
||||
'badgeColor' => null,
|
||||
'runUrl' => null,
|
||||
'historyUrl' => null,
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
@ -539,7 +539,7 @@ public function basisRunSummary(): array
|
||||
'badgeColor' => $badge->color,
|
||||
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
||||
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
@ -551,7 +551,7 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -560,7 +560,7 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
||||
return $this->cachedCoverageTruth;
|
||||
}
|
||||
|
||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||
private function inventorySyncHistoryUrl(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value);
|
||||
}
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\SupportAccessGrant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -38,6 +39,7 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -60,7 +62,17 @@ class AuditLog extends Page implements HasTable
|
||||
'invalidFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateKey' => 'supportAccess',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'managed_environment_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'durable_restorable',
|
||||
@ -84,7 +96,7 @@ class AuditLog extends Page implements HasTable
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id', 'action', 'actor_label', 'resource_type'],
|
||||
'clearsOnTenantSwitch' => ['managed_environment_id', 'action', 'actor_label', 'resource_type'],
|
||||
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
@ -95,12 +107,14 @@ class AuditLog extends Page implements HasTable
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['event'],
|
||||
'shareableStateKeys' => ['event', 'supportAccess'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
public bool $supportAccessOnly = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -118,7 +132,7 @@ class AuditLog extends Page implements HasTable
|
||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
@ -147,6 +161,7 @@ public static function monitoringPageStateContract(): array
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->supportAccessOnly = request()->boolean('supportAccess');
|
||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||
@ -180,6 +195,22 @@ protected function getHeaderActions(): array
|
||||
]);
|
||||
}
|
||||
|
||||
array_splice($actions, 1, 0, [
|
||||
Action::make('support_access_history_filter')
|
||||
->label($this->supportAccessOnly ? 'Show all audit events' : 'Support access history')
|
||||
->icon($this->supportAccessOnly ? 'heroicon-o-list-bullet' : 'heroicon-o-lifebuoy')
|
||||
->color('gray')
|
||||
->url($this->auditLogUrl([
|
||||
'supportAccess' => $this->supportAccessOnly ? null : true,
|
||||
'event' => null,
|
||||
])),
|
||||
Action::make('export_support_access_history')
|
||||
->label('Export support access history')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('gray')
|
||||
->action(fn (): StreamedResponse => $this->exportSupportAccessHistory()),
|
||||
]);
|
||||
|
||||
$selectedAudit = $this->selectedAuditRecord();
|
||||
$selectedAuditLink = $selectedAudit instanceof AuditLogModel
|
||||
? $this->auditTargetLink($selectedAudit)
|
||||
@ -250,7 +281,7 @@ public function table(Table $table): Table
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('recorded_at')
|
||||
@ -259,8 +290,8 @@ public function table(Table $table): Table
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->searchable(),
|
||||
@ -304,7 +335,7 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
@ -320,9 +351,9 @@ public function authorizedTenants(): array
|
||||
}
|
||||
|
||||
$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())
|
||||
->filter(static fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId)
|
||||
->filter(static fn (ManagedEnvironment $tenant): bool => $tenant->isActive())
|
||||
->keyBy(fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
|
||||
return $this->authorizedTenants = $tenants;
|
||||
@ -358,7 +389,7 @@ private function auditBaseQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$authorizedTenantIds = array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||
$this->authorizedTenants(),
|
||||
);
|
||||
|
||||
@ -366,12 +397,15 @@ private function auditBaseQuery(): Builder
|
||||
->with(['tenant', 'workspace', 'operationRun'])
|
||||
->forWorkspace((int) $workspaceId)
|
||||
->where(function (Builder $query) use ($authorizedTenantIds): void {
|
||||
$query->whereNull('tenant_id');
|
||||
$query->whereNull('managed_environment_id');
|
||||
|
||||
if ($authorizedTenantIds !== []) {
|
||||
$query->orWhereIn('tenant_id', $authorizedTenantIds);
|
||||
$query->orWhereIn('managed_environment_id', $authorizedTenantIds);
|
||||
}
|
||||
})
|
||||
->when($this->supportAccessOnly, function (Builder $query): void {
|
||||
$query->whereIn('action', SupportAccessGrant::supportAccessAuditActions());
|
||||
})
|
||||
->latestFirst();
|
||||
}
|
||||
|
||||
@ -433,6 +467,7 @@ private function auditLogUrl(array $overrides = []): string
|
||||
{
|
||||
$parameters = array_merge(
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
['supportAccess' => $this->supportAccessOnly ? true : null],
|
||||
['event' => $this->selectedAuditLogId],
|
||||
$overrides,
|
||||
);
|
||||
@ -508,11 +543,18 @@ private function currentTableSearchState(): string
|
||||
|
||||
private function matchesSelectedAuditFilters(AuditLogModel $record): bool
|
||||
{
|
||||
if (
|
||||
$this->supportAccessOnly
|
||||
&& ! in_array((string) $record->action, SupportAccessGrant::supportAccessAuditActions(), true)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$filters = $this->currentTableFiltersState();
|
||||
|
||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||
$tenantFilter = data_get($filters, 'managed_environment_id.value');
|
||||
|
||||
if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) {
|
||||
if (is_numeric($tenantFilter) && (int) $record->managed_environment_id !== (int) $tenantFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -566,7 +608,7 @@ private function matchesSelectedAuditSearch(AuditLogModel $record): bool
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
@ -576,7 +618,7 @@ private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -632,4 +674,69 @@ private function targetTypeFilterOptions(): array
|
||||
|
||||
return FilterOptionCatalog::auditTargetTypes($values);
|
||||
}
|
||||
|
||||
public function exportSupportAccessHistory(): StreamedResponse
|
||||
{
|
||||
$filename = 'support-access-history-'.now()->format('Ymd-His').'.csv';
|
||||
|
||||
return response()->streamDownload(function (): void {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
fputcsv($handle, [
|
||||
'recorded_at',
|
||||
'action',
|
||||
'outcome',
|
||||
'actor',
|
||||
'target',
|
||||
'scope',
|
||||
'reason',
|
||||
'grant_id',
|
||||
]);
|
||||
|
||||
$this->supportAccessAuditQuery()
|
||||
->reorder()
|
||||
->orderBy('recorded_at')
|
||||
->orderBy('id')
|
||||
->cursor()
|
||||
->each(function (AuditLogModel $record) use ($handle): void {
|
||||
$metadata = is_array($record->metadata) ? $record->metadata : [];
|
||||
|
||||
fputcsv($handle, [
|
||||
$this->csvCell((string) $record->recorded_at?->toISOString()),
|
||||
$this->csvCell((string) $record->action),
|
||||
$this->csvCell($record->normalizedOutcome()->value),
|
||||
$this->csvCell($record->actorDisplayLabel()),
|
||||
$this->csvCell($record->targetDisplayLabel() ?? ''),
|
||||
$this->csvCell((string) ($metadata['scope'] ?? '')),
|
||||
$this->csvCell((string) ($metadata['reason'] ?? '')),
|
||||
$this->csvCell((string) ($metadata['support_access_grant_id'] ?? '')),
|
||||
]);
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
private function supportAccessAuditQuery(): Builder
|
||||
{
|
||||
return $this->auditBaseQuery()
|
||||
->whereIn('action', SupportAccessGrant::supportAccessAuditActions());
|
||||
}
|
||||
|
||||
private function csvCell(string $value): string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
if ($trimmed !== '' && in_array($trimmed[0], ['=', '+', '-', '@'], true)) {
|
||||
return "'".$trimmed;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
@ -46,7 +46,7 @@ class EvidenceOverview extends Page implements HasTable
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateKey' => 'managed_environment_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
@ -90,7 +90,7 @@ class EvidenceOverview extends Page implements HasTable
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id'],
|
||||
'clearsOnTenantSwitch' => ['managed_environment_id'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
@ -101,7 +101,7 @@ class EvidenceOverview extends Page implements HasTable
|
||||
'shareable' => false,
|
||||
'invalidSelectionFallback' => 'discard_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['tenant_id', 'search'],
|
||||
'shareableStateKeys' => ['managed_environment_id', 'search'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
@ -123,7 +123,7 @@ class EvidenceOverview extends Page implements HasTable
|
||||
public array $rows = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $accessibleTenants = null;
|
||||
|
||||
@ -181,14 +181,14 @@ public function table(Table $table): Table
|
||||
return $this->paginateRows($rows, $page, $recordsPerPage);
|
||||
})
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
])
|
||||
->columns([
|
||||
TextColumn::make('tenant_name')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->sortable(),
|
||||
TextColumn::make('artifact_truth_label')
|
||||
->label('Outcome')
|
||||
@ -240,7 +240,7 @@ protected function getHeaderActions(): array
|
||||
public function clearOverviewFilters(): void
|
||||
{
|
||||
$this->tableFilters = [
|
||||
'tenant_id' => ['value' => null],
|
||||
'managed_environment_id' => ['value' => null],
|
||||
];
|
||||
$this->tableDeferredFilters = $this->tableFilters;
|
||||
$this->tableSearch = '';
|
||||
@ -284,7 +284,7 @@ private function authorizeWorkspaceAccess(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
private function accessibleTenants(): array
|
||||
{
|
||||
@ -301,10 +301,10 @@ private function accessibleTenants(): array
|
||||
$workspaceId = $this->workspaceId();
|
||||
|
||||
return $this->accessibleTenants = $user->tenants()
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->where('managed_environments.workspace_id', $workspaceId)
|
||||
->orderBy('managed_environments.name')
|
||||
->get()
|
||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||
->filter(fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@ -315,7 +315,7 @@ private function accessibleTenants(): array
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->accessibleTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -328,11 +328,11 @@ private function tenantFilterOptions(): array
|
||||
private function rowsForState(array $filters = [], ?string $search = null): Collection
|
||||
{
|
||||
$rows = $this->baseRows();
|
||||
$tenantFilter = $this->normalizeTenantFilter($filters['tenant_id']['value'] ?? data_get($this->tableFilters, 'tenant_id.value'));
|
||||
$tenantFilter = $this->normalizeTenantFilter($filters['managed_environment_id']['value'] ?? data_get($this->tableFilters, 'managed_environment_id.value'));
|
||||
$normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch)));
|
||||
|
||||
if ($tenantFilter !== null) {
|
||||
$rows = $rows->where('tenant_id', $tenantFilter);
|
||||
$rows = $rows->where('managed_environment_id', $tenantFilter);
|
||||
}
|
||||
|
||||
if ($normalizedSearch === '') {
|
||||
@ -375,7 +375,7 @@ private function latestAccessibleSnapshots(): Collection
|
||||
}
|
||||
|
||||
$tenantIds = collect($this->accessibleTenants())
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
|
||||
$query = EvidenceSnapshot::query()
|
||||
@ -387,10 +387,10 @@ private function latestAccessibleSnapshots(): Collection
|
||||
if ($tenantIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
} else {
|
||||
$query->whereIn('tenant_id', $tenantIds);
|
||||
$query->whereIn('managed_environment_id', $tenantIds);
|
||||
}
|
||||
|
||||
return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values();
|
||||
return $this->cachedSnapshots = $query->get()->unique('managed_environment_id')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -401,13 +401,13 @@ private function currentReviewTenantIds(Collection $snapshots): array
|
||||
{
|
||||
return TenantReview::query()
|
||||
->where('workspace_id', $this->workspaceId())
|
||||
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
|
||||
->whereIn('managed_environment_id', $snapshots->pluck('managed_environment_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
|
||||
->whereIn('status', [
|
||||
TenantReviewStatus::Draft->value,
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
])
|
||||
->pluck('tenant_id')
|
||||
->pluck('managed_environment_id')
|
||||
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
||||
->all();
|
||||
}
|
||||
@ -420,7 +420,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
||||
{
|
||||
$truth = $this->snapshotTruth($snapshot);
|
||||
$outcome = $this->snapshotOutcome($snapshot);
|
||||
$tenantId = (int) $snapshot->tenant_id;
|
||||
$tenantId = (int) $snapshot->managed_environment_id;
|
||||
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
|
||||
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
|
||||
? 'Create a current review from this evidence snapshot'
|
||||
@ -428,7 +428,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
||||
|
||||
return [
|
||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||
'tenant_id' => $tenantId,
|
||||
'managed_environment_id' => $tenantId,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||
'artifact_truth_label' => $outcome->primaryLabel,
|
||||
@ -443,7 +443,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
||||
],
|
||||
'next_step' => $nextStep,
|
||||
'view_url' => $snapshot->tenant
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'tenant')
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
@ -495,18 +495,18 @@ private function seedTableStateFromQuery(): void
|
||||
$this->tableSearch = trim((string) request()->query('search', ''));
|
||||
}
|
||||
|
||||
if (! array_key_exists('tenant_id', $query)) {
|
||||
if (! array_key_exists('managed_environment_id', $query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id'));
|
||||
$tenantFilter = $this->normalizeTenantFilter(request()->query('managed_environment_id'));
|
||||
|
||||
if ($tenantFilter === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tableFilters = [
|
||||
'tenant_id' => ['value' => (string) $tenantFilter],
|
||||
'managed_environment_id' => ['value' => (string) $tenantFilter],
|
||||
];
|
||||
$this->tableDeferredFilters = $this->tableFilters;
|
||||
}
|
||||
@ -519,7 +519,7 @@ private function normalizeTenantFilter(mixed $value): ?int
|
||||
|
||||
$requestedTenantId = (int) $value;
|
||||
$allowedTenantIds = collect($this->accessibleTenants())
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
|
||||
return in_array($requestedTenantId, $allowedTenantIds, true)
|
||||
@ -529,7 +529,7 @@ private function normalizeTenantFilter(mixed $value): ?int
|
||||
|
||||
private function hasActiveOverviewFilters(): bool
|
||||
{
|
||||
return filled(data_get($this->tableFilters, 'tenant_id.value'))
|
||||
return filled(data_get($this->tableFilters, 'managed_environment_id.value'))
|
||||
|| trim((string) $this->tableSearch) !== '';
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -101,7 +101,7 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant', 'tenant_id', 'status', 'current_validity_state'],
|
||||
'clearsOnTenantSwitch' => ['tenant', 'managed_environment_id', 'status', 'current_validity_state'],
|
||||
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
@ -133,7 +133,7 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
@ -224,7 +224,7 @@ protected function getHeaderActions(): array
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
||||
->action(function (): void {
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
@ -235,15 +235,15 @@ protected function getHeaderActions(): array
|
||||
->label('View tenant register')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
|
||||
->visible(fn (): bool => $this->filteredTenant() instanceof ManagedEnvironment)
|
||||
->url(function (): ?string {
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
return FindingExceptionResource::getUrl('index', tenant: $tenant);
|
||||
});
|
||||
|
||||
$selectedContextActions = [
|
||||
@ -383,7 +383,7 @@ public function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->searchable(),
|
||||
TextColumn::make('finding_summary')
|
||||
->label('Finding')
|
||||
@ -416,8 +416,8 @@ public function table(Table $table): Table
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
@ -443,7 +443,7 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
@ -490,7 +490,7 @@ public function selectedExceptionUrl(): ?string
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
|
||||
FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $record->tenant),
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
);
|
||||
}
|
||||
@ -504,7 +504,7 @@ public function selectedFindingUrl(): ?string
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||
FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant),
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
);
|
||||
}
|
||||
@ -515,7 +515,7 @@ public function clearSelectedException(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
@ -536,12 +536,12 @@ public function authorizedTenants(): array
|
||||
}
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->where('managed_environments.workspace_id', $workspaceId)
|
||||
->orderBy('managed_environments.name')
|
||||
->get();
|
||||
|
||||
return $this->authorizedTenants = $tenants
|
||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->filter(fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@ -550,7 +550,7 @@ private function queueBaseQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantIds = array_values(array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||
$this->authorizedTenants(),
|
||||
));
|
||||
|
||||
@ -565,7 +565,7 @@ private function queueBaseQuery(): Builder
|
||||
'evidenceReferences',
|
||||
])
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
|
||||
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -574,7 +574,7 @@ private function queueBaseQuery(): Builder
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return Collection::make($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -593,14 +593,14 @@ private function applyRequestedTenantPrefilter(): void
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function filteredTenant(): ?Tenant
|
||||
private function filteredTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
@ -741,9 +741,9 @@ private function matchesSelectedFindingExceptionFilters(FindingException $record
|
||||
{
|
||||
$filters = $this->currentQueueFiltersState();
|
||||
|
||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||
$tenantFilter = data_get($filters, 'managed_environment_id.value');
|
||||
|
||||
if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) {
|
||||
if (is_numeric($tenantFilter) && (int) $record->managed_environment_id !== (int) $tenantFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
@ -44,7 +44,7 @@ class Operations extends Page implements HasForms, HasTable
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateKey' => 'managed_environment_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
@ -98,7 +98,7 @@ class Operations extends Page implements HasForms, HasTable
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id', 'type', 'initiator_name'],
|
||||
'clearsOnTenantSwitch' => ['managed_environment_id', 'type', 'initiator_name'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
@ -109,7 +109,7 @@ class Operations extends Page implements HasForms, HasTable
|
||||
'shareable' => false,
|
||||
'invalidSelectionFallback' => 'discard_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['tenant_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
||||
'shareableStateKeys' => ['managed_environment_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
@ -199,15 +199,15 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
} elseif ($activeTenant instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||
->label('Back to '.$activeTenant->name)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
if ($activeTenant instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||
->label('Show all tenants')
|
||||
->color('gray')
|
||||
@ -216,9 +216,9 @@ protected function getHeaderActions(): array
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('managed_environment_id');
|
||||
|
||||
$this->redirect('/admin/operations');
|
||||
$this->redirect(OperationRunLinks::index(allTenants: true));
|
||||
});
|
||||
}
|
||||
|
||||
@ -248,20 +248,20 @@ public function landingHierarchySummary(): array
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$returnLabel = $navigationContext->backLinkLabel;
|
||||
$returnBody = 'Return to the originating monitoring surface without competing with the current tab, filters, or row inspection flow.';
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
} elseif ($activeTenant instanceof ManagedEnvironment) {
|
||||
$returnLabel = 'Back to '.$activeTenant->name;
|
||||
$returnBody = 'Return to the tenant dashboard when you need tenant-specific context outside this workspace monitoring landing.';
|
||||
}
|
||||
|
||||
return [
|
||||
'scope_label' => $operateHubShell->scopeLabel(request()),
|
||||
'scope_body' => $activeTenant instanceof Tenant
|
||||
'scope_body' => $activeTenant instanceof ManagedEnvironment
|
||||
? 'The landing is currently narrowed to one tenant inside the active workspace.'
|
||||
: 'The landing is currently showing workspace-wide monitoring across all entitled tenants.',
|
||||
'return_label' => $returnLabel,
|
||||
'return_body' => $returnBody,
|
||||
'scope_reset_label' => $activeTenant instanceof Tenant ? 'Show all tenants' : null,
|
||||
'scope_reset_body' => $activeTenant instanceof Tenant
|
||||
'scope_reset_label' => $activeTenant instanceof ManagedEnvironment ? 'Show all tenants' : null,
|
||||
'scope_reset_body' => $activeTenant instanceof ManagedEnvironment
|
||||
? 'Reset the landing back to workspace-wide monitoring when tenant-specific context is no longer needed.'
|
||||
: null,
|
||||
'inspect_body' => 'Open a run from the table to enter the canonical monitoring detail viewer.',
|
||||
@ -312,7 +312,7 @@ public function table(Table $table): Table
|
||||
)
|
||||
->when(
|
||||
$tenantFilter !== null,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('managed_environment_id', $tenantFilter),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
@ -393,19 +393,19 @@ private function scopedSummaryQuery(): ?Builder
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when(
|
||||
$tenantFilter !== null,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('managed_environment_id', $tenantFilter),
|
||||
);
|
||||
}
|
||||
|
||||
private function applyRequestedDashboardPrefilter(): void
|
||||
{
|
||||
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('tenant_id'));
|
||||
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('managed_environment_id'));
|
||||
|
||||
if ($requestedTenantId !== null) {
|
||||
$tenantId = (string) $requestedTenantId;
|
||||
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
||||
$this->tableFilters['managed_environment_id']['value'] = $tenantId;
|
||||
$this->tableDeferredFilters['managed_environment_id']['value'] = $tenantId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -432,10 +432,11 @@ private function shouldForceWorkspaceWideTenantScope(): bool
|
||||
private function operationsUrl(array $overrides = []): string
|
||||
{
|
||||
$parameters = array_merge(
|
||||
['workspace' => app(WorkspaceContext::class)->currentWorkspace(request())],
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
[
|
||||
'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null,
|
||||
'tenant_id' => $this->shouldForceWorkspaceWideTenantScope() ? null : $this->currentTenantFilterId(),
|
||||
'managed_environment_id' => $this->shouldForceWorkspaceWideTenantScope() ? null : $this->currentTenantFilterId(),
|
||||
'activeTab' => $this->activeTab !== 'all' ? $this->activeTab : null,
|
||||
'problemClass' => in_array($this->activeTab, self::problemClassTabs(), true) ? $this->activeTab : null,
|
||||
],
|
||||
@ -485,9 +486,9 @@ private function authorizedTenantIds(): array
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->filter(static fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->filter(static fn (ManagedEnvironment $tenant): bool => $tenant->isActive())
|
||||
->map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -108,7 +108,7 @@ protected function getHeaderActions(): array
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->managed_environment_id ?? 0) : 0;
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
@ -122,11 +122,11 @@ protected function getHeaderActions(): array
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
} elseif ($activeTenant instanceof ManagedEnvironment && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||
->label('← Back to '.$activeTenant->name)
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
|
||||
} else {
|
||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||
->label('Back to Operations')
|
||||
@ -134,7 +134,7 @@ protected function getHeaderActions(): array
|
||||
->url(fn (): string => OperationRunLinks::index());
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
if ($activeTenant instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
@ -201,7 +201,7 @@ public function monitoringDetailSummary(): array
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->managed_environment_id ?? 0) : 0;
|
||||
|
||||
$navigationLabel = 'Back to Operations';
|
||||
$navigationBody = 'Return to the operations landing when this review is complete.';
|
||||
@ -209,7 +209,7 @@ public function monitoringDetailSummary(): array
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$navigationLabel = $navigationContext->backLinkLabel;
|
||||
$navigationBody = 'Return to the originating surface while keeping refresh and follow-up work separate from navigation.';
|
||||
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
} elseif ($activeTenant instanceof ManagedEnvironment && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
$navigationLabel = 'Back to '.$activeTenant->name;
|
||||
$navigationBody = 'Return to the active tenant dashboard, then widen back to the workspace view only when you need broader monitoring context.';
|
||||
}
|
||||
@ -391,7 +391,7 @@ private function auditOperationSupportDiagnosticsOpen(): void
|
||||
);
|
||||
}
|
||||
|
||||
private function supportDiagnosticsTenant(): ?Tenant
|
||||
private function supportDiagnosticsTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
@ -399,7 +399,7 @@ private function supportDiagnosticsTenant(): ?Tenant
|
||||
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
@ -417,12 +417,12 @@ private function resolveViewerActor(): User
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resolveRunTenantForCapability(string $capability): Tenant
|
||||
private function resolveRunTenantForCapability(string $capability): ManagedEnvironment
|
||||
{
|
||||
$tenant = $this->supportDiagnosticsTenant();
|
||||
$user = $this->resolveViewerActor();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -444,7 +444,7 @@ private function operationSupportRequestAttachmentSummary(): string
|
||||
$tenant = $this->supportDiagnosticsTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return 'Only canonical redacted run context will be attached.';
|
||||
}
|
||||
|
||||
@ -554,7 +554,7 @@ private function supportRequestNotificationBody(SupportRequest $supportRequest):
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||
private function recordSupportDiagnosticsOpened(ManagedEnvironment $tenant, array $bundle, User $user): void
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return;
|
||||
@ -729,11 +729,11 @@ public function canonicalContextBanner(): ?array
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
$runTenant = $this->run->tenant;
|
||||
|
||||
if (! $runTenant instanceof Tenant) {
|
||||
if (! $runTenant instanceof ManagedEnvironment) {
|
||||
return [
|
||||
'tone' => 'slate',
|
||||
'title' => 'Workspace-level operation',
|
||||
'body' => $activeTenant instanceof Tenant
|
||||
'body' => $activeTenant instanceof ManagedEnvironment
|
||||
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
|
||||
: 'This canonical workspace view is not tied to any tenant.',
|
||||
];
|
||||
@ -743,7 +743,7 @@ public function canonicalContextBanner(): ?array
|
||||
$tone = 'sky';
|
||||
$title = null;
|
||||
|
||||
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
|
||||
if ($activeTenant instanceof ManagedEnvironment && ! $activeTenant->is($runTenant)) {
|
||||
$title = 'Current tenant context differs from this operation';
|
||||
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
|
||||
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
|
||||
@ -759,7 +759,7 @@ public function canonicalContextBanner(): ?array
|
||||
if ($referencedTenant->contextNote !== null) {
|
||||
$messages[] = $referencedTenant->contextNote;
|
||||
}
|
||||
} elseif (! $activeTenant instanceof Tenant) {
|
||||
} elseif (! $activeTenant instanceof ManagedEnvironment) {
|
||||
$title ??= 'Canonical workspace view';
|
||||
$messages[] = 'No tenant context is currently selected.';
|
||||
}
|
||||
@ -925,7 +925,7 @@ private function canResumeCapture(): bool
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
private function relatedLinksTenant(): ?Tenant
|
||||
private function relatedLinksTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
@ -934,7 +934,7 @@ private function relatedLinksTenant(): ?Tenant
|
||||
$user = auth()->user();
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -5,18 +5,23 @@
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -36,6 +41,7 @@
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -45,7 +51,7 @@ class CustomerReviewWorkspace extends Page implements HasTable
|
||||
|
||||
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
||||
|
||||
private const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||
public const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
@ -67,10 +73,11 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
|
||||
->withPrimaryLinkColumnReason('Only the dedicated review-open column should navigate away; the rest of the row stays comparative workspace context.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The dedicated open link column opens the latest published review detail instead of an inline canonical detail panel.');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
@ -88,7 +95,7 @@ public function getTitle(): string
|
||||
return __('localization.review.customer_review_workspace');
|
||||
}
|
||||
|
||||
public static function tenantPrefilterUrl(Tenant $tenant): string
|
||||
public static function tenantPrefilterUrl(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$tenantIdentifier = filled($tenant->external_id)
|
||||
? (string) $tenant->external_id
|
||||
@ -100,7 +107,7 @@ public static function tenantPrefilterUrl(Tenant $tenant): string
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
@ -109,6 +116,7 @@ public function mount(): void
|
||||
$this->authorizePageAccess();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->mountInteractsWithTable();
|
||||
$this->auditWorkspaceOpen();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
@ -146,37 +154,44 @@ public function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->recordUrl(null)
|
||||
->columns([
|
||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
|
||||
TextColumn::make('latest_review')
|
||||
->label(__('localization.review.latest_review'))
|
||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable(),
|
||||
TextColumn::make('package_availability')
|
||||
->label(__('localization.review.governance_package'))
|
||||
->width('9rem')
|
||||
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
||||
->badge()
|
||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
||||
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
|
||||
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
|
||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
||||
->getStateUsing(fn (ManagedEnvironment $record): string => $this->governancePackageAvailabilityLabel($record))
|
||||
->color(fn (ManagedEnvironment $record): string => $this->governancePackageAvailabilityColor($record))
|
||||
->tooltip(fn (ManagedEnvironment $record): string => $this->governancePackageAvailability($record)['description']),
|
||||
TextColumn::make('latest_review')
|
||||
->label(__('localization.review.status'))
|
||||
->width('9rem')
|
||||
->badge()
|
||||
->getStateUsing(fn (ManagedEnvironment $record): string => $this->latestReviewStateLabel($record))
|
||||
->color(fn (ManagedEnvironment $record): string => $this->latestReviewStateColor($record)),
|
||||
TextColumn::make('evidence_proof_state')
|
||||
->label(__('localization.review.evidence_status'))
|
||||
->width('8rem')
|
||||
->badge()
|
||||
->getStateUsing(fn (ManagedEnvironment $record): string => $this->evidenceStatusLabel($record))
|
||||
->color(fn (ManagedEnvironment $record): string => $this->evidenceStatusColor($record)),
|
||||
TextColumn::make('recommended_next_action')
|
||||
->label(__('localization.review.next_step'))
|
||||
->width('10rem')
|
||||
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
||||
->getStateUsing(fn (ManagedEnvironment $record): string => $this->controlRecommendedNextAction($record))
|
||||
->wrap(),
|
||||
TextColumn::make('finding_summary')
|
||||
->label(__('localization.review.key_findings'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('accepted_risk_summary')
|
||||
->label(__('localization.review.accepted_risks'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('published_at')
|
||||
->label(__('localization.review.published'))
|
||||
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('review_pack_state')
|
||||
->label(__('localization.review.review_pack'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
||||
TextColumn::make('open_review')
|
||||
->label(__('localization.review.open'))
|
||||
->width('8rem')
|
||||
->getStateUsing(fn (): string => __('localization.review.open_review'))
|
||||
->url(fn (ManagedEnvironment $record): ?string => $this->latestReviewUrl($record))
|
||||
->color('primary'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label(__('localization.review.tenant'))
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
@ -189,24 +204,12 @@ public function table(Table $table): Table
|
||||
})
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Action::make('open_latest_review')
|
||||
->label(__('localization.review.open_latest_review'))
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
|
||||
Action::make('download_review_pack')
|
||||
->label(__('localization.review.download_review_pack'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
|
||||
->emptyStateHeading(__('localization.review.no_released_customer_reviews'))
|
||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||
? __('localization.review.clear_filters_description')
|
||||
: __('localization.review.adjust_filters_description'))
|
||||
: __('localization.review.no_released_customer_reviews_description'))
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters_empty')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
@ -218,7 +221,7 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
@ -260,13 +263,41 @@ private function authorizePageAccess(): void
|
||||
}
|
||||
}
|
||||
|
||||
private function auditWorkspaceOpen(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::CustomerReviewWorkspaceOpened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||
'entitled_tenant_count' => count($this->authorizedTenants()),
|
||||
'interpretation_version' => $this->currentTenantFilterInterpretationVersion(),
|
||||
'interpretation_versions' => $this->visibleInterpretationVersions(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'customer_review_workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
targetLabel: __('localization.review.customer_review_workspace'),
|
||||
);
|
||||
}
|
||||
|
||||
private function workspaceQuery(): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return Tenant::query()->whereRaw('1 = 0');
|
||||
return ManagedEnvironment::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
|
||||
@ -278,7 +309,7 @@ private function workspaceQuery(): Builder
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -295,7 +326,7 @@ private function defaultTenantFilter(): ?string
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
|
||||
$requestedTenant = request()->query('tenant', request()->query('managed_environment_id'));
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
@ -306,8 +337,8 @@ private function applyRequestedTenantPrefilter(): void
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -328,10 +359,10 @@ private function clearWorkspaceFilters(): void
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
$tenantFilter = data_get($this->tableFilters, 'managed_environment_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'managed_environment_id.value');
|
||||
}
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
@ -346,14 +377,14 @@ private function workspace(): ?Workspace
|
||||
: null;
|
||||
}
|
||||
|
||||
private function latestPublishedReview(Tenant $tenant): ?TenantReview
|
||||
private function latestPublishedReview(ManagedEnvironment $tenant): ?TenantReview
|
||||
{
|
||||
$review = $tenant->tenantReviews->first();
|
||||
|
||||
return $review instanceof TenantReview ? $review : null;
|
||||
}
|
||||
|
||||
private function latestReviewUrl(Tenant $tenant): ?string
|
||||
private function latestReviewUrl(ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
@ -361,55 +392,27 @@ private function latestReviewUrl(Tenant $tenant): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
|
||||
$query = array_filter(
|
||||
array_replace(
|
||||
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
||||
[
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||
],
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
),
|
||||
static fn (mixed $value): bool => $value !== null && $value !== '',
|
||||
);
|
||||
|
||||
return $this->appendQuery(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant), $query);
|
||||
}
|
||||
|
||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
$pack = $review?->currentExportReviewPack;
|
||||
|
||||
return $pack instanceof ReviewPack ? $pack : null;
|
||||
}
|
||||
|
||||
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
|
||||
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
]);
|
||||
}
|
||||
|
||||
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
|
||||
private function latestPublishedAt(ManagedEnvironment $tenant): ?\Illuminate\Support\Carbon
|
||||
{
|
||||
return $this->latestPublishedReview($tenant)?->published_at;
|
||||
}
|
||||
|
||||
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
|
||||
private function reviewTruth(ManagedEnvironment $tenant): ?ArtifactTruthEnvelope
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
@ -418,7 +421,7 @@ private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
|
||||
: null;
|
||||
}
|
||||
|
||||
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
||||
private function reviewOutcome(ManagedEnvironment $tenant): ?CompressedGovernanceOutcome
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
@ -432,27 +435,49 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
||||
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
|
||||
}
|
||||
|
||||
private function latestReviewStateLabel(Tenant $tenant): string
|
||||
private function latestReviewStateLabel(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review');
|
||||
}
|
||||
|
||||
return $this->workspaceReviewNeedsAttention($tenant)
|
||||
? __('localization.review.review_requires_attention')
|
||||
: __('localization.review.ready_for_release');
|
||||
}
|
||||
|
||||
private function latestReviewStateColor(Tenant $tenant): string
|
||||
private function latestReviewStateColor(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
$packageState = $this->governancePackageAvailability($tenant)['state'];
|
||||
|
||||
if (! $this->workspaceReviewNeedsAttention($tenant)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
return in_array($packageState, ['blocked', 'expired'], true)
|
||||
? 'danger'
|
||||
: 'warning';
|
||||
}
|
||||
|
||||
private function latestReviewStateIcon(Tenant $tenant): ?string
|
||||
private function latestReviewStateIcon(ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
|
||||
}
|
||||
|
||||
private function latestReviewStateIconColor(Tenant $tenant): ?string
|
||||
private function latestReviewStateIconColor(ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
|
||||
}
|
||||
|
||||
private function reviewOutcomeDescription(Tenant $tenant): ?string
|
||||
private function reviewOutcomeDescription(ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
@ -477,7 +502,343 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
||||
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
||||
}
|
||||
|
||||
private function findingSummary(Tenant $tenant): string
|
||||
private function controlReadinessLabel(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$control = $this->primaryControlSummary($tenant);
|
||||
|
||||
if ($control === null) {
|
||||
return __('localization.review.control_readiness_unmapped');
|
||||
}
|
||||
|
||||
$label = $control['readiness_label'] ?? null;
|
||||
|
||||
return is_string($label) && trim($label) !== ''
|
||||
? $label
|
||||
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function governancePackageSummary(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
||||
|
||||
return $package;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,label:string,description:string}
|
||||
*/
|
||||
private function governancePackageAvailability(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.no_published_review_available'),
|
||||
];
|
||||
}
|
||||
|
||||
$pack = $review->currentExportReviewPack;
|
||||
$user = auth()->user();
|
||||
$limitations = is_array($review->controlInterpretation()['limitations'] ?? null) ? $review->controlInterpretation()['limitations'] : [];
|
||||
$isPartialReview = in_array((string) $review->completeness_state, [
|
||||
TenantReviewCompletenessState::Partial->value,
|
||||
TenantReviewCompletenessState::Stale->value,
|
||||
], true) || $limitations !== [];
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_unavailable_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return [
|
||||
'state' => 'blocked',
|
||||
'label' => __('localization.review.governance_package_blocked'),
|
||||
'description' => __('localization.review.governance_package_blocked_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
|
||||
return [
|
||||
'state' => 'expired',
|
||||
'label' => __('localization.review.governance_package_expired'),
|
||||
'description' => __('localization.review.governance_package_expired_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_not_ready_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPartialReview) {
|
||||
return [
|
||||
'state' => 'partial',
|
||||
'label' => __('localization.review.governance_package_partial'),
|
||||
'description' => __('localization.review.governance_package_partial_description'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'available',
|
||||
'label' => __('localization.review.governance_package_available'),
|
||||
'description' => __('localization.review.governance_package_available_description'),
|
||||
];
|
||||
}
|
||||
|
||||
private function governancePackageAvailabilityLabel(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||
'available' => __('localization.review.available'),
|
||||
'partial' => __('localization.review.partial'),
|
||||
'blocked' => __('localization.review.blocked'),
|
||||
'expired' => __('localization.review.expired'),
|
||||
default => __('localization.review.unavailable'),
|
||||
};
|
||||
}
|
||||
|
||||
private function governancePackageAvailabilityColor(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||
'available' => 'success',
|
||||
'partial' => 'warning',
|
||||
'blocked', 'expired' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function governancePackageTeaser(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$package = $this->governancePackageSummary($tenant);
|
||||
|
||||
$executiveSummary = $package['executive_summary'] ?? null;
|
||||
|
||||
if (is_string($executiveSummary) && trim($executiveSummary) !== '') {
|
||||
return $executiveSummary;
|
||||
}
|
||||
|
||||
return $this->governancePackageAvailability($tenant)['description'];
|
||||
}
|
||||
|
||||
private function controlReadinessColor(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return match ((string) ($this->primaryControlSummary($tenant)['readiness_bucket'] ?? 'unmapped')) {
|
||||
'follow_up_required' => 'warning',
|
||||
'review_recommended' => 'info',
|
||||
'evidence_on_record' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function controlReadinessDescription(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$controls = $review->controlInterpretationControls();
|
||||
|
||||
if ($controls === []) {
|
||||
return __('localization.review.control_readiness_unmapped_description');
|
||||
}
|
||||
|
||||
$summary = collect($controls)
|
||||
->take(2)
|
||||
->map(function (array $control): string {
|
||||
$name = is_string($control['control_name'] ?? null) ? $control['control_name'] : __('localization.review.control');
|
||||
$label = is_string($control['readiness_label'] ?? null)
|
||||
? $control['readiness_label']
|
||||
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
||||
|
||||
return $name.': '.$label;
|
||||
})
|
||||
->implode(' · ');
|
||||
|
||||
$remaining = count($controls) - 2;
|
||||
|
||||
if ($remaining > 0) {
|
||||
$summary .= ' · '.__('localization.review.additional_controls', ['count' => $remaining]);
|
||||
}
|
||||
|
||||
$limitations = $this->controlLimitationSummary($review);
|
||||
|
||||
return trim($summary.($limitations !== null ? ' '.$limitations : ''));
|
||||
}
|
||||
|
||||
private function controlEvidenceBasisSummary(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$control = $this->primaryControlSummary($tenant);
|
||||
|
||||
if ($control === null) {
|
||||
return __('localization.review.control_evidence_unmapped');
|
||||
}
|
||||
|
||||
$summary = $control['evidence_basis_summary'] ?? null;
|
||||
|
||||
return is_string($summary) && trim($summary) !== ''
|
||||
? $summary
|
||||
: __('localization.review.control_evidence_unavailable');
|
||||
}
|
||||
|
||||
private function controlRecommendedNextAction(ManagedEnvironment $tenant): string
|
||||
{
|
||||
if ($this->primaryControlSummary($tenant) === null) {
|
||||
return __('localization.review.workspace_next_step_control_mapping');
|
||||
}
|
||||
|
||||
if ($this->evidenceStatusState($tenant) !== 'available') {
|
||||
return __('localization.review.workspace_next_step_evidence_review');
|
||||
}
|
||||
|
||||
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||
'available', 'partial' => __('localization.review.workspace_next_step_package_review'),
|
||||
default => __('localization.review.workspace_next_step_review_open'),
|
||||
};
|
||||
}
|
||||
|
||||
private function workspaceReviewNeedsAttention(ManagedEnvironment $tenant): bool
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->primaryControlSummary($tenant) === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->evidenceStatusState($tenant) !== 'available') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->governancePackageAvailability($tenant)['state'] !== 'available';
|
||||
}
|
||||
|
||||
private function evidenceStatusState(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return 'restricted';
|
||||
}
|
||||
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
return 'available';
|
||||
}
|
||||
|
||||
private function evidenceStatusLabelForState(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'available' => __('localization.review.available'),
|
||||
'restricted' => __('localization.review.restricted'),
|
||||
'expired' => __('localization.review.expired'),
|
||||
default => __('localization.review.pending'),
|
||||
};
|
||||
}
|
||||
|
||||
private function evidenceStatusColorForState(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'available' => 'success',
|
||||
'restricted', 'expired' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function controlRecommendedNextActionDescription(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$control = $this->primaryControlSummary($tenant);
|
||||
|
||||
if ($control === null) {
|
||||
return __('localization.review.control_recommendation_unmapped');
|
||||
}
|
||||
|
||||
$action = $control['recommended_next_action'] ?? null;
|
||||
|
||||
return is_string($action) && trim($action) !== ''
|
||||
? $action
|
||||
: __('localization.review.no_action_needed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function primaryControlSummary(ManagedEnvironment $tenant): ?array
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$controls = collect($review->controlInterpretationControls());
|
||||
|
||||
return $controls
|
||||
->sortBy(static fn (array $control): int => match ((string) ($control['readiness_bucket'] ?? '')) {
|
||||
'follow_up_required' => 0,
|
||||
'review_recommended' => 1,
|
||||
'evidence_on_record' => 2,
|
||||
default => 3,
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
private function controlLimitationSummary(TenantReview $review): ?string
|
||||
{
|
||||
$counts = $review->controlInterpretationLimitationCounts();
|
||||
|
||||
if ($counts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$labels = collect($counts)
|
||||
->filter(static fn (int $count): bool => $count > 0)
|
||||
->keys()
|
||||
->map(static fn (string $flag): string => ComplianceEvidenceMappingV1::limitationLabel($flag))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $labels === []
|
||||
? null
|
||||
: __('localization.review.control_limitations_summary', ['limitations' => implode(', ', $labels)]);
|
||||
}
|
||||
|
||||
private function findingSummary(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
@ -504,7 +865,7 @@ private function findingSummary(Tenant $tenant): string
|
||||
]);
|
||||
}
|
||||
|
||||
private function acceptedRiskSummary(Tenant $tenant): string
|
||||
private function acceptedRiskSummary(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
@ -518,31 +879,142 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
||||
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||
|
||||
return match (true) {
|
||||
$countSummary = match (true) {
|
||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
||||
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
||||
};
|
||||
|
||||
$accountability = $this->acceptedRiskAccountability($tenant);
|
||||
|
||||
return $accountability === null
|
||||
? $countSummary
|
||||
: $countSummary.' '.$accountability;
|
||||
}
|
||||
|
||||
private function reviewPackAvailability(Tenant $tenant): string
|
||||
private function evidenceProofAvailability(ManagedEnvironment $tenant): string
|
||||
{
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.unavailable');
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.unavailable');
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return __('localization.review.evidence_proof_absent');
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.unavailable');
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return __('localization.review.evidence_proof_access_unavailable');
|
||||
}
|
||||
|
||||
return __('localization.review.available');
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return __('localization.review.evidence_proof_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.evidence_proof_available');
|
||||
}
|
||||
|
||||
private function evidenceStatusLabel(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return $this->evidenceStatusLabelForState($this->evidenceStatusState($tenant));
|
||||
}
|
||||
|
||||
private function evidenceStatusColor(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return $this->evidenceStatusColorForState($this->evidenceStatusState($tenant));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function visibleInterpretationVersions(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(TenantReviewRegisterService::class)
|
||||
->latestPublishedQuery($user, $workspace)
|
||||
->get()
|
||||
->map(static fn (TenantReview $review): ?string => $review->controlInterpretationVersion())
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function currentTenantFilterInterpretationVersion(): ?string
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
if ($tenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->authorizedTenants()[$tenantId] ?? null;
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant->tenantReviews()->published()
|
||||
->latest('published_at')
|
||||
->latest('generated_at')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->controlInterpretationVersion();
|
||||
}
|
||||
|
||||
private function acceptedRiskAccountability(ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
$exception = FindingException::query()
|
||||
->with(['owner', 'approver', 'currentDecision'])
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->current()
|
||||
->orderByRaw("case when current_validity_state in ('valid', 'expiring') then 0 else 1 end")
|
||||
->latest('approved_at')
|
||||
->latest('requested_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accountable = $exception->owner?->name
|
||||
?? $exception->approver?->name;
|
||||
$decisionType = $exception->currentDecision?->decision_type;
|
||||
$reviewDue = $exception->review_due_at ?? $exception->expires_at;
|
||||
$reason = is_string($exception->request_reason) ? trim($exception->request_reason) : '';
|
||||
$parts = [];
|
||||
|
||||
if (is_string($accountable) && trim($accountable) !== '') {
|
||||
$parts[] = $reviewDue === null
|
||||
? __('localization.review.accepted_risk_accountable', ['name' => $accountable])
|
||||
: __('localization.review.accepted_risk_accountable_until', [
|
||||
'name' => $accountable,
|
||||
'date' => $reviewDue->toDateString(),
|
||||
]);
|
||||
} elseif (is_string($decisionType) && trim($decisionType) !== '') {
|
||||
$parts[] = __('localization.review.accepted_risk_partial_accountability');
|
||||
}
|
||||
|
||||
if ($reason !== '') {
|
||||
$parts[] = __('localization.review.accepted_risk_reason', [
|
||||
'reason' => Str::limit($reason, 160),
|
||||
]);
|
||||
}
|
||||
|
||||
return $parts === [] ? null : implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -61,7 +61,7 @@ class ReviewRegister extends Page implements HasTable
|
||||
protected string $view = 'filament.pages.reviews.review-register';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
@ -112,9 +112,9 @@ public function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
||||
TextColumn::make('tenant.name')->label('ManagedEnvironment')->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
@ -138,8 +138,8 @@ public function table(Table $table): Table
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->searchable(),
|
||||
@ -210,7 +210,7 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
@ -270,7 +270,7 @@ private function registerQuery(): Builder
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
@ -298,8 +298,8 @@ private function applyRequestedTenantPrefilter(): void
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -323,10 +323,10 @@ private function clearRegisterFilters(): void
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
$tenantFilter = data_get($this->tableFilters, 'managed_environment_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'managed_environment_id.value');
|
||||
}
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
|
||||
@ -4,18 +4,23 @@
|
||||
|
||||
namespace App\Filament\Pages\Settings;
|
||||
|
||||
use App\Models\SupportAccessGrant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Support\Ai\AiPolicyMode;
|
||||
use App\Support\Ai\AiUseCaseCatalog;
|
||||
use App\Services\Auth\SupportAccessGrantManager;
|
||||
use App\Services\Auth\SupportAccessGrantResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Ai\AiPolicyMode;
|
||||
use App\Support\Ai\AiUseCaseCatalog;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use App\Support\Settings\SettingDefinition;
|
||||
use App\Support\Settings\SettingsRegistry;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -141,6 +146,11 @@ class WorkspaceSettings extends Page
|
||||
*/
|
||||
public array $entitlementSummary = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $commercialLifecycleSummary = [];
|
||||
|
||||
/**
|
||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||
*
|
||||
@ -227,6 +237,51 @@ public function content(Schema $schema): Schema
|
||||
->helperText(fn (): string => $this->localeDefaultHelperText())
|
||||
->hintAction($this->makeResetAction('localization_default_locale')),
|
||||
]),
|
||||
Section::make('Commercial posture')
|
||||
->description('Read-only subscription-backed or fallback-backed commercial posture for this workspace.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('commercial_posture_source')
|
||||
->label('Commercial source')
|
||||
->content(fn (): string => $this->commercialPostureSourceText()),
|
||||
Placeholder::make('commercial_posture_state')
|
||||
->label('Commercial state')
|
||||
->content(fn (): string => $this->commercialPostureStateText()),
|
||||
Placeholder::make('commercial_posture_timing')
|
||||
->label('Commercial timing')
|
||||
->content(fn (): string => $this->commercialPostureTimingText()),
|
||||
Placeholder::make('commercial_posture_reason')
|
||||
->label('Explanation')
|
||||
->content(fn (): string => $this->commercialPostureReasonText())
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Workspace lifecycle')
|
||||
->description('Read-only workspace closure posture. Closed workspaces keep history visible but block new tenant and workspace mutations.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('workspace_closure_posture')
|
||||
->label('Lifecycle')
|
||||
->content(fn (): string => $this->workspace->isClosed() ? 'Closed' : 'Open'),
|
||||
Placeholder::make('workspace_closed_at')
|
||||
->label('Closed at')
|
||||
->content(fn (): string => $this->workspace->closed_at?->toDayDateTimeString() ?? 'Not closed'),
|
||||
Placeholder::make('workspace_closure_reason')
|
||||
->label('Closure reason')
|
||||
->content(fn (): string => $this->workspace->closureReason() ?? 'Not closed')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Support access approval')
|
||||
->description('Review current support-access posture and decide pending workspace recovery requests.')
|
||||
->columns(2)
|
||||
->afterHeader(fn (): array => $this->supportAccessApprovalActions())
|
||||
->schema([
|
||||
Placeholder::make('support_access_posture')
|
||||
->label('Current support access')
|
||||
->content(fn (): string => $this->supportAccessPostureText()),
|
||||
Placeholder::make('support_access_pending_recovery')
|
||||
->label('Pending recovery requests')
|
||||
->content(fn (): string => $this->pendingSupportAccessRequestsText()),
|
||||
]),
|
||||
Section::make('Workspace entitlements')
|
||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||
->columns(2)
|
||||
@ -653,6 +708,7 @@ private function loadFormState(): void
|
||||
$this->workspaceOverrides = $workspaceOverrides;
|
||||
$this->resolvedSettings = $resolvedSettings;
|
||||
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||
$this->commercialLifecycleSummary = app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
|
||||
|
||||
$this->loadDomainLastModified();
|
||||
}
|
||||
@ -697,6 +753,125 @@ private function loadDomainLastModified(): void
|
||||
$this->domainLastModified = $domainInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
private function supportAccessApprovalActions(): array
|
||||
{
|
||||
$grant = $this->currentPendingSupportAccessGrant();
|
||||
|
||||
if (! $grant instanceof SupportAccessGrant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Action::make('approve_support_access')
|
||||
->label('Approve recovery access')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Approve recovery support access')
|
||||
->modalDescription('This activates a time-limited workspace recovery support grant. Owner repair will still require active break-glass mode.')
|
||||
->visible(fn (): bool => $this->currentUserCanApproveSupportAccess())
|
||||
->action(function (): void {
|
||||
$this->approveSupportAccess();
|
||||
}),
|
||||
Action::make('deny_support_access')
|
||||
->label('Deny request')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Deny recovery support access')
|
||||
->modalDescription('This denies the pending recovery support request and keeps owner repair blocked for this workspace.')
|
||||
->visible(fn (): bool => $this->currentUserCanApproveSupportAccess())
|
||||
->action(function (): void {
|
||||
$this->denySupportAccess();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function approveSupportAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$grant = $this->currentPendingSupportAccessGrant();
|
||||
|
||||
if (! $user instanceof User || ! $grant instanceof SupportAccessGrant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
app(SupportAccessGrantManager::class)->approve($grant, $user);
|
||||
$this->loadFormState();
|
||||
|
||||
Notification::make()
|
||||
->title('Recovery access approved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function denySupportAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$grant = $this->currentPendingSupportAccessGrant();
|
||||
|
||||
if (! $user instanceof User || ! $grant instanceof SupportAccessGrant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
app(SupportAccessGrantManager::class)->deny($grant, $user);
|
||||
$this->loadFormState();
|
||||
|
||||
Notification::make()
|
||||
->title('Recovery access denied')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function supportAccessPostureText(): string
|
||||
{
|
||||
$summary = app(SupportAccessGrantResolver::class)->summaryFor($this->workspace);
|
||||
|
||||
if (($summary['grant_id'] ?? null) === null) {
|
||||
return 'No active or pending support access is recorded for this workspace.';
|
||||
}
|
||||
|
||||
$parts = [
|
||||
(string) $summary['status_label'],
|
||||
(string) $summary['scope_label'],
|
||||
'requested by '.(string) $summary['requester_label'],
|
||||
'TTL '.$summary['requested_ttl_label'],
|
||||
];
|
||||
|
||||
if (is_string($summary['expires_label'] ?? null)) {
|
||||
$parts[] = 'expires '.$summary['expires_label'];
|
||||
}
|
||||
|
||||
return implode(' · ', array_filter($parts));
|
||||
}
|
||||
|
||||
private function pendingSupportAccessRequestsText(): string
|
||||
{
|
||||
$requests = app(SupportAccessGrantResolver::class)->pendingRecoveryRequestsFor($this->workspace);
|
||||
|
||||
if ($requests->isEmpty()) {
|
||||
return 'No workspace recovery request is waiting for owner approval.';
|
||||
}
|
||||
|
||||
return $requests
|
||||
->map(fn (SupportAccessGrant $grant): string => sprintf(
|
||||
'#%d · %s · %s · %d minutes',
|
||||
(int) $grant->getKey(),
|
||||
$grant->requester?->name ?? $grant->requester?->email ?? 'Platform support',
|
||||
(string) $grant->reason,
|
||||
(int) $grant->ttl_minutes,
|
||||
))
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
private function currentPendingSupportAccessGrant(): ?SupportAccessGrant
|
||||
{
|
||||
return app(SupportAccessGrantResolver::class)
|
||||
->pendingRecoveryRequestsFor($this->workspace)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a section description that appends "last modified" info when available.
|
||||
*/
|
||||
@ -945,6 +1120,43 @@ private function entitlementSourceLabel(array $decision): string
|
||||
return 'plan profile default';
|
||||
}
|
||||
|
||||
private function commercialPostureSourceText(): string
|
||||
{
|
||||
return ($this->commercialLifecycleSummary['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
|
||||
}
|
||||
|
||||
private function commercialPostureStateText(): string
|
||||
{
|
||||
return (string) ($this->commercialLifecycleSummary['subscription_state_label']
|
||||
?? $this->commercialLifecycleSummary['state_label']
|
||||
?? 'Active paid');
|
||||
}
|
||||
|
||||
private function commercialPostureTimingText(): string
|
||||
{
|
||||
$label = $this->commercialLifecycleSummary['subscription_key_date_label'] ?? null;
|
||||
$date = $this->commercialLifecycleSummary['subscription_key_date'] ?? null;
|
||||
|
||||
if (is_string($label) && $label !== '' && $date instanceof Carbon) {
|
||||
return sprintf('%s: %s', $label, $date->toDayDateTimeString());
|
||||
}
|
||||
|
||||
return 'No scheduled commercial date recorded.';
|
||||
}
|
||||
|
||||
private function commercialPostureReasonText(): string
|
||||
{
|
||||
$reason = $this->commercialLifecycleSummary['rationale'] ?? null;
|
||||
|
||||
if (is_string($reason) && $reason !== '') {
|
||||
return $reason;
|
||||
}
|
||||
|
||||
return ($this->commercialLifecycleSummary['fallback_status'] ?? true)
|
||||
? 'No current subscription record is stored. The workspace is using fallback lifecycle truth.'
|
||||
: 'No explicit commercial explanation recorded.';
|
||||
}
|
||||
|
||||
private function helperTextFor(string $field): string
|
||||
{
|
||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||
@ -1543,9 +1755,27 @@ private function currentUserCanManage(): bool
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $this->workspace)
|
||||
&& ! $this->workspace->isClosed()
|
||||
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
||||
}
|
||||
|
||||
private function currentUserCanApproveSupportAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $this->workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $this->workspace)
|
||||
&& ! $this->workspace->isClosed()
|
||||
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)
|
||||
&& $resolver->getRole($user, $this->workspace) === WorkspaceRole::Owner;
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceView(User $user): void
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
@ -1572,5 +1802,11 @@ private function authorizeWorkspaceManage(User $user): void
|
||||
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($this->workspace->isClosed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'workspace' => 'This workspace is closed. Reopen it before changing workspace settings.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Filament\Pages\Tenancy;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -43,7 +43,7 @@ public static function canView(): bool
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||
$tenantIds = $user->tenants()->withTrashed()->pluck('managed_environments.id');
|
||||
|
||||
if ($tenantIds->isEmpty()) {
|
||||
return false;
|
||||
@ -52,7 +52,7 @@ public static function canView(): bool
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||
foreach (ManagedEnvironment::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return true;
|
||||
}
|
||||
@ -77,8 +77,8 @@ public function form(Schema $schema): Schema
|
||||
])
|
||||
->default('other')
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('tenant_id')
|
||||
->label('Tenant ID (GUID)')
|
||||
Forms\Components\TextInput::make('managed_environment_id')
|
||||
->label('ManagedEnvironment ID (GUID)')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
@ -104,7 +104,7 @@ protected function handleRegistration(array $data): Model
|
||||
$data['workspace_id'] = $workspaceId;
|
||||
}
|
||||
|
||||
$tenant = Tenant::create($data);
|
||||
$tenant = ManagedEnvironment::create($data);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
|
||||
@ -4,16 +4,14 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
||||
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Filament\Widgets\Dashboard\TenantDashboardContextChips;
|
||||
use App\Filament\Widgets\Dashboard\TenantDashboardOverview;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -23,7 +21,10 @@
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||
use App\Support\TenantDashboard\TenantDashboardSummary;
|
||||
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
@ -32,29 +33,100 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
|
||||
class TenantDashboard extends Dashboard
|
||||
{
|
||||
protected Width|string|null $maxContentWidth = Width::Full;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
public function getTitle(): string
|
||||
private ?TenantDashboardSummary $dashboardSummary = null;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('localization.dashboard.tenant_title');
|
||||
}
|
||||
|
||||
public function getTitle(): string | Htmlable
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return __('localization.dashboard.tenant_title');
|
||||
}
|
||||
|
||||
$summary = $this->dashboardSummary();
|
||||
|
||||
if (! $summary instanceof TenantDashboardSummary) {
|
||||
return (string) $tenant->name;
|
||||
}
|
||||
|
||||
return new HtmlString(sprintf(
|
||||
'<span class="inline-flex flex-wrap items-center gap-3" data-testid="tenant-dashboard-heading"><span>%s</span><span data-testid="tenant-dashboard-posture-pill" class="%s">%s</span></span>',
|
||||
e((string) $tenant->name),
|
||||
e($this->posturePillClasses((string) ($summary->posture['tone'] ?? 'gray'))),
|
||||
e((string) ($summary->posture['status'] ?? '')),
|
||||
));
|
||||
}
|
||||
|
||||
public function getSubheading(): string | Htmlable | null
|
||||
{
|
||||
return __('localization.dashboard.overview.page_subheading');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
|
||||
$resolvedTenant = $tenant instanceof ManagedEnvironment
|
||||
? $tenant
|
||||
: (($parameters['tenant'] ?? $parameters['environment'] ?? null) instanceof ManagedEnvironment
|
||||
? ($parameters['tenant'] ?? $parameters['environment'])
|
||||
: null);
|
||||
|
||||
if (! $resolvedTenant instanceof ManagedEnvironment) {
|
||||
return url('/admin');
|
||||
}
|
||||
|
||||
$workspace = $parameters['workspace'] ?? null;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
$workspace = $resolvedTenant->workspace()->first();
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return url('/admin');
|
||||
}
|
||||
|
||||
return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$resolvedTenant->getRouteKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||
*/
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
TenantDashboardContextChips::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHeaderWidgetsColumns(): int|array
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -64,18 +136,14 @@ public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
TenantTriageArrivalContinuity::class,
|
||||
RecoveryReadiness::class,
|
||||
DashboardKpis::class,
|
||||
NeedsAttention::class,
|
||||
BaselineCompareNow::class,
|
||||
RecentDriftFindings::class,
|
||||
RecentOperations::class,
|
||||
TenantDashboardOverview::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 2;
|
||||
return ['default' => 1, 'xl' => 12];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,10 +151,193 @@ public function getColumns(): int|array
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$actions = [];
|
||||
|
||||
if ($primaryAction = $this->primaryFollowUpHeaderAction()) {
|
||||
$actions[] = $primaryAction;
|
||||
}
|
||||
|
||||
$moreActions = array_values(array_filter([
|
||||
$this->secondaryHeaderAction(),
|
||||
$this->requestSupportAction(),
|
||||
$this->openSupportDiagnosticsAction(),
|
||||
];
|
||||
]));
|
||||
|
||||
if ($moreActions !== []) {
|
||||
$actions[] = ActionGroup::make($moreActions)
|
||||
->label(__('localization.dashboard.more_actions'))
|
||||
->icon('heroicon-o-ellipsis-horizontal')
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function primaryFollowUpHeaderAction(): ?Action
|
||||
{
|
||||
$payload = $this->primaryFollowUpHeaderPayload();
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return $this->governanceInboxHeaderAction();
|
||||
}
|
||||
|
||||
return $this->summaryHeaderAction(
|
||||
name: 'primaryFollowUp',
|
||||
payload: $payload,
|
||||
color: 'primary',
|
||||
icon: 'heroicon-o-bolt',
|
||||
);
|
||||
}
|
||||
|
||||
private function secondaryHeaderAction(): ?Action
|
||||
{
|
||||
$payload = $this->secondaryHeaderPayload();
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->summaryHeaderAction(
|
||||
name: 'reviewOutput',
|
||||
payload: $payload,
|
||||
color: 'gray',
|
||||
icon: 'heroicon-o-document-duplicate',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function primaryFollowUpHeaderPayload(): ?array
|
||||
{
|
||||
$summary = $this->dashboardSummary();
|
||||
|
||||
if (! $summary instanceof TenantDashboardSummary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $summary->recommendedActions[0] ?? null;
|
||||
|
||||
return is_array($payload) && filled($payload['actionLabel'] ?? null)
|
||||
? $payload
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function secondaryHeaderPayload(): ?array
|
||||
{
|
||||
$summary = $this->dashboardSummary();
|
||||
|
||||
if (! $summary instanceof TenantDashboardSummary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$primaryPayload = $this->primaryFollowUpHeaderPayload();
|
||||
|
||||
foreach ($summary->readinessCards as $payload) {
|
||||
if (! is_array($payload) || ! filled($payload['actionLabel'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($payload['key'] ?? null, ['customer_safe_output', 'current_review'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
is_array($primaryPayload)
|
||||
&& ($payload['actionLabel'] ?? null) === ($primaryPayload['actionLabel'] ?? null)
|
||||
&& ($payload['actionUrl'] ?? null) === ($primaryPayload['actionUrl'] ?? null)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function governanceInboxHeaderAction(): ?Action
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Action::make('primaryFollowUp')
|
||||
->label(__('localization.dashboard.overview.action_open_governance_inbox'))
|
||||
->icon('heroicon-o-inbox-stack')
|
||||
->color('primary')
|
||||
->url(GovernanceInbox::getUrl(panel: 'admin').'?'.http_build_query([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function summaryHeaderAction(string $name, array $payload, string $color, string $icon): ?Action
|
||||
{
|
||||
$label = $payload['actionLabel'] ?? null;
|
||||
$url = $payload['actionUrl'] ?? null;
|
||||
$helperText = $payload['helperText'] ?? null;
|
||||
|
||||
if (! is_string($label) || $label === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((! is_string($url) || $url === '') && (! is_string($helperText) || $helperText === '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$action = Action::make($name)
|
||||
->label($label)
|
||||
->icon($icon)
|
||||
->color($color);
|
||||
|
||||
if (is_string($url) && $url !== '') {
|
||||
$action->url($url);
|
||||
} else {
|
||||
$action->disabled();
|
||||
}
|
||||
|
||||
if (is_string($helperText) && $helperText !== '') {
|
||||
$action->tooltip($helperText);
|
||||
}
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function dashboardSummary(): ?TenantDashboardSummary
|
||||
{
|
||||
if ($this->dashboardSummary instanceof TenantDashboardSummary) {
|
||||
return $this->dashboardSummary;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->dashboardSummary = app(TenantDashboardSummaryBuilder::class)->build($tenant, $user);
|
||||
|
||||
return $this->dashboardSummary;
|
||||
}
|
||||
|
||||
private function posturePillClasses(string $tone): string
|
||||
{
|
||||
return match ($tone) {
|
||||
'success' => 'inline-flex items-center rounded-full border border-success-200 bg-success-50 px-3 py-1 text-sm font-medium text-success-700 shadow-sm dark:border-success-800 dark:bg-success-500/10 dark:text-success-300',
|
||||
'danger' => 'inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 text-sm font-medium text-danger-700 shadow-sm dark:border-danger-800 dark:bg-danger-500/10 dark:text-danger-300',
|
||||
'warning' => 'inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 text-sm font-medium text-warning-700 shadow-sm dark:border-warning-800 dark:bg-warning-500/10 dark:text-warning-300',
|
||||
default => 'inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 text-sm font-medium text-gray-700 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-gray-300',
|
||||
};
|
||||
}
|
||||
|
||||
public function authorizeTenantSupportRequest(): void
|
||||
@ -244,7 +495,7 @@ private function auditTenantSupportDiagnosticsOpen(): void
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||
private function recordSupportDiagnosticsOpened(ManagedEnvironment $tenant, array $bundle, User $user): void
|
||||
{
|
||||
$auditKey = 'tenant:'.$tenant->getKey();
|
||||
|
||||
@ -285,12 +536,12 @@ private function resolveDashboardActor(): User
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resolveCurrentTenantForCapability(string $capability): Tenant
|
||||
private function resolveCurrentTenantForCapability(string $capability): ManagedEnvironment
|
||||
{
|
||||
$user = $this->resolveDashboardActor();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -312,7 +563,7 @@ private function tenantSupportRequestAttachmentSummary(): string
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return 'Only canonical redacted tenant context will be attached.';
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
@ -32,7 +32,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'ManagedEnvironment diagnostics is already the singleton diagnostic surface for the active tenant.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.');
|
||||
@ -47,8 +47,8 @@ public function mount(): void
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! TenantMembership::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
$this->missingOwner = ! ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
@ -38,7 +38,7 @@ class TenantRequiredPermissions extends Page implements HasTable
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
||||
protected static ?string $slug = 'workspaces/{workspace}/environments/{tenant}/required-permissions';
|
||||
|
||||
protected static ?string $title = 'Required permissions';
|
||||
|
||||
@ -69,16 +69,16 @@ public static function canAccess(): bool
|
||||
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
||||
}
|
||||
|
||||
public function currentTenant(): ?Tenant
|
||||
public function currentTenant(): ?ManagedEnvironment
|
||||
{
|
||||
return $this->trustedScopedTenant();
|
||||
}
|
||||
|
||||
public function mount(Tenant|string|null $tenant = null): void
|
||||
public function mount(ManagedEnvironment|string|null $tenant = null): void
|
||||
{
|
||||
$tenant = static::resolveScopedTenant($tenant);
|
||||
|
||||
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! static::hasScopedTenantAccess($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -206,7 +206,7 @@ public function reRunVerificationUrl(): string
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
}
|
||||
|
||||
@ -217,53 +217,53 @@ public function manageProviderConnectionUrl(): ?string
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(Tenant|string|null $tenant = null): ?Tenant
|
||||
protected static function resolveScopedTenant(ManagedEnvironment|string|null $tenant = null): ?ManagedEnvironment
|
||||
{
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if (is_string($tenant) && $tenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenant)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $tenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
$routeTenant = request()->route('tenant');
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
if ($routeTenant instanceof ManagedEnvironment) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $routeTenant)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $routeTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
$queryTenant = request()->query('tenant');
|
||||
|
||||
if (is_string($queryTenant) && $queryTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $queryTenant)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $queryTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
||||
private static function hasScopedTenantAccess(?ManagedEnvironment $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -285,7 +285,7 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
private function trustedScopedTenant(): ?Tenant
|
||||
private function trustedScopedTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -303,7 +303,7 @@ private function trustedScopedTenant(): ?Tenant
|
||||
|
||||
$routeTenant = static::resolveScopedTenant();
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
if ($routeTenant instanceof ManagedEnvironment) {
|
||||
try {
|
||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
||||
} catch (NotFoundHttpException) {
|
||||
@ -315,9 +315,9 @@ private function trustedScopedTenant(): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
|
||||
$tenant = ManagedEnvironment::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -350,7 +350,7 @@ private function viewModelForState(array $state): array
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@ -16,8 +16,8 @@
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
@ -133,7 +133,7 @@ protected function getLayoutData(): array
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
public ?Tenant $managedTenant = null;
|
||||
public ?ManagedEnvironment $managedTenant = null;
|
||||
|
||||
#[Locked]
|
||||
public ?int $managedTenantId = null;
|
||||
@ -190,7 +190,7 @@ protected function getHeaderActions(): array
|
||||
$actions[] = Action::make('view_linked_tenant')
|
||||
->label($this->linkedTenantActionLabel())
|
||||
->color('gray')
|
||||
->url($tenant instanceof Tenant ? TenantResource::getUrl('view', ['record' => $tenant]) : null);
|
||||
->url($tenant instanceof ManagedEnvironment ? TenantResource::getUrl('view', ['record' => $tenant]) : null);
|
||||
}
|
||||
|
||||
if ($this->canResumeDraft($draft)) {
|
||||
@ -224,7 +224,7 @@ private function canViewLinkedTenant(): bool
|
||||
$user = auth()->user();
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -245,7 +245,7 @@ private function linkedTenantActionLabel(): string
|
||||
{
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'View tenant';
|
||||
}
|
||||
|
||||
@ -325,10 +325,10 @@ public function content(Schema $schema): Schema
|
||||
Step::make('Identify managed tenant')
|
||||
->description('Create or resume a managed tenant in this workspace.')
|
||||
->schema([
|
||||
Section::make('Tenant')
|
||||
Section::make('ManagedEnvironment')
|
||||
->schema([
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Tenant ID (GUID)')
|
||||
->label('ManagedEnvironment ID (GUID)')
|
||||
->required()
|
||||
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
||||
->rules(['uuid'])
|
||||
@ -373,7 +373,7 @@ public function content(Schema $schema): Schema
|
||||
]);
|
||||
} catch (NotFoundHttpException) {
|
||||
Notification::make()
|
||||
->title('Tenant not available')
|
||||
->title('ManagedEnvironment not available')
|
||||
->body('This tenant cannot be onboarded in this workspace.')
|
||||
->danger()
|
||||
->send();
|
||||
@ -475,7 +475,7 @@ public function content(Schema $schema): Schema
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
||||
TextInput::make('new_connection.target_scope_id')
|
||||
->label('Target scope ID')
|
||||
->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '')
|
||||
->default(fn (): string => $this->currentManagedTenantRecord()?->managed_environment_id ?? '')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||
@ -518,7 +518,7 @@ public function content(Schema $schema): Schema
|
||||
]),
|
||||
])
|
||||
->afterValidation(function (): void {
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
if (! $this->managedTenant instanceof ManagedEnvironment) {
|
||||
throw new Halt;
|
||||
}
|
||||
|
||||
@ -567,7 +567,7 @@ public function content(Schema $schema): Schema
|
||||
SchemaActions::make([
|
||||
Action::make('wizardStartVerification')
|
||||
->label('Start verification')
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive())
|
||||
->visible(fn (): bool => $this->managedTenant instanceof ManagedEnvironment && ! $this->verificationRunIsActive())
|
||||
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
||||
? null
|
||||
@ -583,7 +583,7 @@ public function content(Schema $schema): Schema
|
||||
->default(null)
|
||||
->view('filament.forms.components.managed-tenant-onboarding-verification-report')
|
||||
->viewData(fn (): array => $this->verificationReportViewData())
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant),
|
||||
->visible(fn (): bool => $this->managedTenant instanceof ManagedEnvironment),
|
||||
]),
|
||||
])
|
||||
->beforeValidation(function (): void {
|
||||
@ -612,7 +612,7 @@ public function content(Schema $schema): Schema
|
||||
SchemaActions::make([
|
||||
Action::make('wizardStartBootstrap')
|
||||
->label('Start bootstrap')
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
||||
->visible(fn (): bool => $this->managedTenant instanceof ManagedEnvironment)
|
||||
->disabled(fn (): bool => ! $this->canStartAnyBootstrap())
|
||||
->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
|
||||
? null
|
||||
@ -646,7 +646,7 @@ public function content(Schema $schema): Schema
|
||||
->compact()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Text::make('Tenant')
|
||||
Text::make('ManagedEnvironment')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->completionSummaryTenantLine())
|
||||
->weight(FontWeight::SemiBold),
|
||||
@ -683,7 +683,7 @@ public function content(Schema $schema): Schema
|
||||
->info()
|
||||
->footer([
|
||||
UnorderedList::make([
|
||||
'Tenant status will be set to Active.',
|
||||
'ManagedEnvironment status will be set to Active.',
|
||||
'Backup, inventory, and compliance operations become available.',
|
||||
'The provider connection will be used for provider API calls.',
|
||||
]),
|
||||
@ -705,7 +705,7 @@ public function content(Schema $schema): Schema
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Complete onboarding')
|
||||
->modalDescription(fn (): string => $this->managedTenant instanceof Tenant
|
||||
->modalDescription(fn (): string => $this->managedTenant instanceof ManagedEnvironment
|
||||
? sprintf('Are you sure you want to complete onboarding for "%s"? This will make the tenant operational.', $this->managedTenant->name)
|
||||
: 'Are you sure you want to complete onboarding for this tenant?')
|
||||
->modalSubmitActionLabel('Yes, complete onboarding')
|
||||
@ -758,7 +758,7 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
|
||||
|
||||
$tenant = $draft->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
|
||||
if ($tenant instanceof ManagedEnvironment && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
|
||||
$this->setManagedTenant($tenant);
|
||||
}
|
||||
|
||||
@ -974,7 +974,7 @@ private function resumeContextSchema(): array
|
||||
->collapsed()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Text::make('Tenant')
|
||||
Text::make('ManagedEnvironment')
|
||||
->color('gray'),
|
||||
Text::make(fn () => $this->draftTitle($this->currentOnboardingSessionRecord() ?? $draft))
|
||||
->weight(FontWeight::SemiBold),
|
||||
@ -1152,6 +1152,11 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke
|
||||
}
|
||||
|
||||
$schema = [
|
||||
Text::make('Primary provider capability')
|
||||
->color('gray'),
|
||||
Text::make($this->readinessPrimaryCapabilityLine($permissions))
|
||||
->badge()
|
||||
->color($this->readinessPrimaryCapabilityColor($permissions)),
|
||||
Text::make('Missing application permissions')
|
||||
->color('gray'),
|
||||
Text::make((string) $missingApplication)
|
||||
@ -1204,7 +1209,7 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke
|
||||
* provider_summary: array<string, mixed>|null,
|
||||
* verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, has_report: bool, matches_selected_connection: bool|null, overall: string|null},
|
||||
* verification_assist: array{is_visible: bool, reason: string},
|
||||
* permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null,
|
||||
* permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, capability_groups: array<int, array<string, mixed>>, primary_capability_group: array<string, mixed>|null, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null,
|
||||
* freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string},
|
||||
* blocker: array{reason_code: string|null, blocking_reason_code: string|null, operator_summary: string},
|
||||
* next_action: array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null},
|
||||
@ -1225,7 +1230,7 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
||||
? $snapshot['last_completed_checkpoint']
|
||||
: null;
|
||||
|
||||
$tenant = $draft->tenant instanceof Tenant ? $draft->tenant : null;
|
||||
$tenant = $draft->tenant instanceof ManagedEnvironment ? $draft->tenant : null;
|
||||
$providerConnection = $this->readinessProviderConnection($draft);
|
||||
$selectedProviderConnectionId = $providerConnection instanceof ProviderConnection
|
||||
? (int) $providerConnection->getKey()
|
||||
@ -1240,7 +1245,7 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
||||
$verificationMatchesSelectedConnection = $verificationRun instanceof OperationRun
|
||||
? $this->readinessRunMatchesSelectedConnection($verificationRun, $selectedProviderConnectionId)
|
||||
: null;
|
||||
$permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null;
|
||||
$permissions = $tenant instanceof ManagedEnvironment ? $this->readinessPermissionOverview($tenant) : null;
|
||||
$verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null;
|
||||
$verificationReport = is_array($verificationReport) ? $verificationReport : null;
|
||||
$verificationPrimaryReasonCode = $verificationReport !== null
|
||||
@ -1300,7 +1305,7 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
||||
? $this->readinessVerificationOverall($verificationRun, $verificationReport)
|
||||
: null,
|
||||
],
|
||||
'verification_assist' => $tenant instanceof Tenant && $verificationReport !== null
|
||||
'verification_assist' => $tenant instanceof ManagedEnvironment && $verificationReport !== null
|
||||
? app(VerificationAssistViewModelBuilder::class)->visibility($tenant, $verificationReport)
|
||||
: $this->hiddenVerificationAssistVisibility(),
|
||||
'permissions' => $permissions,
|
||||
@ -1443,14 +1448,14 @@ private function readinessProviderConnection(TenantOnboardingSession $draft): ?P
|
||||
$state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null,
|
||||
);
|
||||
|
||||
if ($providerConnectionId === null || $draft->tenant_id === null) {
|
||||
if ($providerConnectionId === null || $draft->managed_environment_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnection::query()
|
||||
->whereKey($providerConnectionId)
|
||||
->where('workspace_id', (int) $draft->workspace_id)
|
||||
->where('tenant_id', (int) $draft->tenant_id)
|
||||
->where('managed_environment_id', (int) $draft->managed_environment_id)
|
||||
->first();
|
||||
}
|
||||
|
||||
@ -1473,22 +1478,22 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr
|
||||
'verification_state' => $this->stringValue($connection->verification_status),
|
||||
'readiness_summary' => 'Target scope needs review',
|
||||
'target_scope_summary' => 'Target scope needs review',
|
||||
'provider_context' => [
|
||||
'provider' => (string) $connection->provider,
|
||||
'details' => [],
|
||||
],
|
||||
'contextual_identity_line' => null,
|
||||
'is_enabled' => (bool) $connection->is_enabled,
|
||||
];
|
||||
}
|
||||
|
||||
return array_merge($summary->toArray(), [
|
||||
'target_scope_summary' => $summary->targetScopeSummary(),
|
||||
'contextual_identity_line' => $summary->contextualIdentityLine(),
|
||||
'is_enabled' => (bool) $connection->is_enabled,
|
||||
]);
|
||||
return $summary->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}
|
||||
* @return array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, capability_groups: array<int, array<string, mixed>>, primary_capability_group: array<string, mixed>|null, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}
|
||||
*/
|
||||
private function readinessPermissionOverview(Tenant $tenant): array
|
||||
private function readinessPermissionOverview(ManagedEnvironment $tenant): array
|
||||
{
|
||||
$viewModel = app(TenantRequiredPermissionsViewModelBuilder::class)->build($tenant, [
|
||||
'status' => 'all',
|
||||
@ -1500,6 +1505,10 @@ private function readinessPermissionOverview(Tenant $tenant): array
|
||||
$overview = is_array($viewModel['overview'] ?? null) ? $viewModel['overview'] : [];
|
||||
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
||||
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
||||
$capabilityGroups = is_array($overview['capability_groups'] ?? null) ? $overview['capability_groups'] : [];
|
||||
$primaryCapabilityGroup = is_array($overview['primary_capability_group'] ?? null)
|
||||
? $overview['primary_capability_group']
|
||||
: null;
|
||||
$permissions = is_array($viewModel['permissions'] ?? null) ? $viewModel['permissions'] : [];
|
||||
|
||||
return [
|
||||
@ -1514,6 +1523,8 @@ private function readinessPermissionOverview(Tenant $tenant): array
|
||||
'last_refreshed_at' => is_string($freshness['last_refreshed_at'] ?? null) ? $freshness['last_refreshed_at'] : null,
|
||||
'is_stale' => (bool) ($freshness['is_stale'] ?? true),
|
||||
],
|
||||
'capability_groups' => $capabilityGroups,
|
||||
'primary_capability_group' => $primaryCapabilityGroup,
|
||||
'missing_permissions' => [
|
||||
'application' => $this->readinessMissingPermissionKeys($permissions, 'application'),
|
||||
'delegated' => $this->readinessMissingPermissionKeys($permissions, 'delegated'),
|
||||
@ -1540,6 +1551,54 @@ private function readinessMissingPermissionKeys(array $permissions, string $type
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $permissions
|
||||
*/
|
||||
private function readinessPrimaryCapabilityLabel(?array $permissions): ?string
|
||||
{
|
||||
$primary = is_array($permissions['primary_capability_group'] ?? null)
|
||||
? $permissions['primary_capability_group']
|
||||
: null;
|
||||
|
||||
if (! is_array($primary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = is_string($primary['label'] ?? null) ? trim((string) $primary['label']) : '';
|
||||
|
||||
return $label !== '' ? $label : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $permissions
|
||||
*/
|
||||
private function readinessPrimaryCapabilityLine(array $permissions): string
|
||||
{
|
||||
$primary = is_array($permissions['primary_capability_group'] ?? null)
|
||||
? $permissions['primary_capability_group']
|
||||
: [];
|
||||
$label = is_string($primary['label'] ?? null) && trim((string) $primary['label']) !== ''
|
||||
? trim((string) $primary['label'])
|
||||
: 'Provider capability';
|
||||
$status = is_string($primary['status'] ?? null) ? trim((string) $primary['status']) : 'unknown';
|
||||
$statusLabel = BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, $status)->label;
|
||||
|
||||
return "{$label}: {$statusLabel}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $permissions
|
||||
*/
|
||||
private function readinessPrimaryCapabilityColor(array $permissions): string
|
||||
{
|
||||
$primary = is_array($permissions['primary_capability_group'] ?? null)
|
||||
? $permissions['primary_capability_group']
|
||||
: [];
|
||||
$status = is_string($primary['status'] ?? null) ? trim((string) $primary['status']) : 'unknown';
|
||||
|
||||
return BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, $status)->color;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $permissions
|
||||
* @param 'application'|'delegated' $type
|
||||
@ -1608,7 +1667,7 @@ private function readinessSummaryText(
|
||||
bool $verificationMismatch,
|
||||
): string {
|
||||
if (! $this->readinessDraftHasTenantIdentity($draft)) {
|
||||
return 'Tenant identity required';
|
||||
return 'ManagedEnvironment identity required';
|
||||
}
|
||||
|
||||
if (! $providerConnection instanceof ProviderConnection) {
|
||||
@ -1646,11 +1705,19 @@ private function readinessSummaryText(
|
||||
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
|
||||
|
||||
if ($verificationStatus === 'blocked' || $permissionOverall === VerificationReportOverall::Blocked->value) {
|
||||
return 'Permission or consent blocker needs attention';
|
||||
$capabilityLabel = $this->readinessPrimaryCapabilityLabel($permissions);
|
||||
|
||||
return $capabilityLabel !== null
|
||||
? "{$capabilityLabel} capability needs attention"
|
||||
: 'Permission or consent blocker needs attention';
|
||||
}
|
||||
|
||||
if ($permissionOverall === VerificationReportOverall::NeedsAttention->value || (bool) ($permissions['freshness']['is_stale'] ?? false)) {
|
||||
return 'Readiness needs attention';
|
||||
$capabilityLabel = $this->readinessPrimaryCapabilityLabel($permissions);
|
||||
|
||||
return $capabilityLabel !== null
|
||||
? "{$capabilityLabel} capability needs refreshed evidence"
|
||||
: 'Readiness needs attention';
|
||||
}
|
||||
|
||||
return match ($lifecycleState) {
|
||||
@ -1704,7 +1771,7 @@ private function readinessNextAction(
|
||||
return $this->readinessAction(
|
||||
label: 'Grant admin consent',
|
||||
kind: 'grant_consent',
|
||||
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
||||
url: $draft->tenant instanceof ManagedEnvironment ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1718,15 +1785,15 @@ private function readinessNextAction(
|
||||
return $this->readinessAction(
|
||||
label: 'Grant admin consent',
|
||||
kind: 'grant_consent',
|
||||
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
||||
url: $draft->tenant instanceof ManagedEnvironment ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($permissionOverall === VerificationReportOverall::Blocked->value) {
|
||||
return $this->readinessAction(
|
||||
label: 'Review permissions',
|
||||
label: 'Review provider capability',
|
||||
kind: 'review_permissions',
|
||||
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::requiredPermissions($draft->tenant) : null,
|
||||
url: $draft->tenant instanceof ManagedEnvironment ? RequiredPermissionsLinks::requiredPermissions($draft->tenant) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1862,7 +1929,7 @@ private function readinessConnectionRecentlyUpdated(TenantOnboardingSession $dra
|
||||
|
||||
private function readinessDraftHasTenantIdentity(TenantOnboardingSession $draft): bool
|
||||
{
|
||||
if ($draft->tenant_id !== null) {
|
||||
if ($draft->managed_environment_id !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1981,7 +2048,7 @@ private function resumeOnboardingDraft(int $draftId, bool $logSelection): void
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'onboarding_session_id' => (int) $draft->getKey(),
|
||||
'tenant_db_id' => $draft->tenant_id !== null ? (int) $draft->tenant_id : null,
|
||||
'tenant_db_id' => $draft->managed_environment_id !== null ? (int) $draft->managed_environment_id : null,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
@ -2055,7 +2122,7 @@ private function cancelOnboardingDraft(): void
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
|
||||
'tenant_db_id' => $this->onboardingSession->managed_environment_id !== null ? (int) $this->onboardingSession->managed_environment_id : null,
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
],
|
||||
],
|
||||
@ -2067,7 +2134,7 @@ private function cancelOnboardingDraft(): void
|
||||
|
||||
$normalizedTenant = $this->lifecycleService()->syncLinkedTenantAfterCancellation($this->onboardingSession);
|
||||
|
||||
if ($normalizedTenant instanceof Tenant) {
|
||||
if ($normalizedTenant instanceof ManagedEnvironment) {
|
||||
app(WorkspaceAuditLogger::class)->logTenantLifecycleAction(
|
||||
tenant: $normalizedTenant,
|
||||
action: AuditActionId::TenantReturnedToDraft,
|
||||
@ -2129,7 +2196,7 @@ private function deleteOnboardingDraft(): void
|
||||
$draftTitle = $this->draftTitle($draft);
|
||||
$draftStatus = $draft->status()->value;
|
||||
$draftLifecycle = $draft->lifecycleState()->value;
|
||||
$tenantId = $draft->tenant_id !== null ? (int) $draft->tenant_id : null;
|
||||
$tenantId = $draft->managed_environment_id !== null ? (int) $draft->managed_environment_id : null;
|
||||
|
||||
$draft->delete();
|
||||
|
||||
@ -2312,14 +2379,14 @@ private function setOnboardingSession(?TenantOnboardingSession $draft): void
|
||||
? $draft->expectedVersion()
|
||||
: null;
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof ManagedEnvironment) {
|
||||
$this->setManagedTenant($draft->tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant_id !== null) {
|
||||
$this->managedTenantId = (int) $draft->tenant_id;
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->managed_environment_id !== null) {
|
||||
$this->managedTenantId = (int) $draft->managed_environment_id;
|
||||
|
||||
return;
|
||||
}
|
||||
@ -2327,14 +2394,14 @@ private function setOnboardingSession(?TenantOnboardingSession $draft): void
|
||||
$this->setManagedTenant(null);
|
||||
}
|
||||
|
||||
private function setManagedTenant(?Tenant $tenant): void
|
||||
private function setManagedTenant(?ManagedEnvironment $tenant): void
|
||||
{
|
||||
$this->managedTenant = $tenant;
|
||||
$this->managedTenantId = $tenant instanceof Tenant
|
||||
$this->managedTenantId = $tenant instanceof ManagedEnvironment
|
||||
? (int) $tenant->getKey()
|
||||
: null;
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession && $tenant instanceof Tenant) {
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession && $tenant instanceof ManagedEnvironment) {
|
||||
$this->onboardingSession->setRelation('tenant', $tenant);
|
||||
}
|
||||
}
|
||||
@ -2362,15 +2429,15 @@ private function currentOnboardingSessionRecord(): ?TenantOnboardingSession
|
||||
return $query->first();
|
||||
}
|
||||
|
||||
private function currentManagedTenantRecord(): ?Tenant
|
||||
private function currentManagedTenantRecord(): ?ManagedEnvironment
|
||||
{
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof ManagedEnvironment) {
|
||||
return $draft->tenant;
|
||||
}
|
||||
|
||||
if ($this->managedTenant instanceof Tenant
|
||||
if ($this->managedTenant instanceof ManagedEnvironment
|
||||
&& $this->managedTenantId !== null
|
||||
&& (int) $this->managedTenant->getKey() === $this->managedTenantId) {
|
||||
return $this->managedTenant;
|
||||
@ -2380,7 +2447,7 @@ private function currentManagedTenantRecord(): ?Tenant
|
||||
return $this->managedTenant;
|
||||
}
|
||||
|
||||
$query = Tenant::query()->withTrashed()->whereKey($this->managedTenantId);
|
||||
$query = ManagedEnvironment::query()->withTrashed()->whereKey($this->managedTenantId);
|
||||
|
||||
if (isset($this->workspace)) {
|
||||
$query->where('workspace_id', (int) $this->workspace->getKey());
|
||||
@ -2497,7 +2564,7 @@ public function refreshCheckpointLifecycle(): void
|
||||
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$this->setManagedTenant($tenant->fresh());
|
||||
}
|
||||
|
||||
@ -2527,9 +2594,9 @@ private function initializeWizardData(): void
|
||||
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
|
||||
$this->data['new_connection']['target_scope_id'] ??= (string) $tenant->tenant_id;
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$this->data['entra_tenant_id'] ??= (string) $tenant->managed_environment_id;
|
||||
$this->data['new_connection']['target_scope_id'] ??= (string) $tenant->managed_environment_id;
|
||||
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
|
||||
$this->data['name'] ??= (string) $tenant->name;
|
||||
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
|
||||
@ -2608,14 +2675,14 @@ private function providerConnectionOptions(): array
|
||||
{
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ProviderConnection::query()
|
||||
->with('tenant')
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('display_name')
|
||||
->get()
|
||||
@ -2658,7 +2725,10 @@ private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $
|
||||
'shared_label' => 'Target scope',
|
||||
'shared_help_text' => 'The platform scope this provider connection represents.',
|
||||
],
|
||||
'provider_identity_context' => [],
|
||||
'provider_context' => [
|
||||
'provider' => (string) $connection->provider,
|
||||
'details' => [],
|
||||
],
|
||||
], $extra);
|
||||
}
|
||||
}
|
||||
@ -2868,12 +2938,12 @@ private function verificationReportViewData(): array
|
||||
$previousRunUrl = $this->verificationPreviousRunUrl($changeIndicator);
|
||||
|
||||
$user = auth()->user();
|
||||
$canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant
|
||||
$canAcknowledge = $user instanceof User && $this->managedTenant instanceof ManagedEnvironment
|
||||
? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)
|
||||
: false;
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('managed_environment_id', (int) $run->managed_environment_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
@ -2965,7 +3035,7 @@ private function verificationContextualHelp(array $verificationReport, Operation
|
||||
{
|
||||
$tenant = $this->managedTenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -3056,13 +3126,13 @@ public function acknowledgeVerificationCheckAction(): Action
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
if (! $this->managedTenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $this->managedTenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -3108,7 +3178,7 @@ public function acknowledgeVerificationCheckAction(): Action
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
if (! $this->managedTenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -3235,7 +3305,7 @@ private function touchOnboardingSessionStep(string $step): void
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
|
||||
'tenant_db_id' => $this->onboardingSession->managed_environment_id !== null ? (int) $this->onboardingSession->managed_environment_id : null,
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
'current_step' => $step,
|
||||
],
|
||||
@ -3282,17 +3352,17 @@ private function authorizeEditableDraft(User $user): void
|
||||
}
|
||||
}
|
||||
|
||||
private function trustedManagedTenantForUser(User $user): Tenant
|
||||
private function trustedManagedTenantForUser(User $user): ManagedEnvironment
|
||||
{
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $tenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -3309,7 +3379,7 @@ private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $draft->tenant instanceof Tenant) {
|
||||
if (! $draft->tenant instanceof ManagedEnvironment) {
|
||||
return $this->lifecycleService()->canResumeDraft($draft);
|
||||
}
|
||||
|
||||
@ -3334,12 +3404,12 @@ private function authorizeWorkspaceMember(User $user): void
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
|
||||
private function resolveWorkspaceIdForUnboundTenant(ManagedEnvironment $tenant): ?int
|
||||
{
|
||||
$workspaceId = DB::table('tenant_memberships')
|
||||
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
|
||||
->where('tenant_memberships.tenant_id', (int) $tenant->getKey())
|
||||
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
||||
$workspaceId = DB::table('managed_environment_memberships')
|
||||
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'managed_environment_memberships.user_id')
|
||||
->where('managed_environment_memberships.managed_environment_id', (int) $tenant->getKey())
|
||||
->orderByRaw("CASE managed_environment_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
||||
->value('workspace_memberships.workspace_id');
|
||||
|
||||
return $workspaceId === null ? null : (int) $workspaceId;
|
||||
@ -3385,13 +3455,13 @@ public function identifyManagedTenant(array $data): void
|
||||
$currentDraftId = $this->onboardingSession?->getKey();
|
||||
$sessionWasCreated = false;
|
||||
|
||||
$existingTenant = Tenant::query()
|
||||
$existingTenant = ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $entraTenantId)
|
||||
->where('slug', $entraTenantId)
|
||||
->first();
|
||||
|
||||
if ($existingTenant instanceof Tenant) {
|
||||
if ($existingTenant->trashed() || $existingTenant->status === Tenant::STATUS_ARCHIVED) {
|
||||
if ($existingTenant instanceof ManagedEnvironment) {
|
||||
if ($existingTenant->trashed() || $existingTenant->status === ManagedEnvironment::STATUS_ARCHIVED) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -3411,7 +3481,7 @@ public function identifyManagedTenant(array $data): void
|
||||
'name' => $tenantName,
|
||||
'environment' => $environment,
|
||||
'domain' => $primaryDomain,
|
||||
'status' => $existingTenant->status === Tenant::STATUS_DRAFT ? Tenant::STATUS_ONBOARDING : $existingTenant->status,
|
||||
'status' => $existingTenant->status === ManagedEnvironment::STATUS_DRAFT ? ManagedEnvironment::STATUS_ONBOARDING : $existingTenant->status,
|
||||
'metadata' => array_merge(is_array($existingTenant->metadata) ? $existingTenant->metadata : [], array_filter([
|
||||
'notes' => $notes,
|
||||
], static fn ($value): bool => $value !== null)),
|
||||
@ -3420,30 +3490,30 @@ public function identifyManagedTenant(array $data): void
|
||||
$tenant = $existingTenant;
|
||||
} else {
|
||||
try {
|
||||
$tenant = Tenant::query()->create([
|
||||
$tenant = ManagedEnvironment::query()->create([
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'name' => $tenantName,
|
||||
'tenant_id' => $entraTenantId,
|
||||
'slug' => $entraTenantId,
|
||||
'domain' => $primaryDomain,
|
||||
'environment' => $environment,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'status' => ManagedEnvironment::STATUS_ONBOARDING,
|
||||
'metadata' => array_filter([
|
||||
'notes' => $notes,
|
||||
], static fn ($value): bool => $value !== null),
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
// Race-safe global uniqueness: if another workspace created the tenant_id first,
|
||||
// Race-safe global uniqueness: if another workspace created the managed_environment_id first,
|
||||
// treat it as deny-as-not-found.
|
||||
$existingTenant = Tenant::query()
|
||||
$existingTenant = ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $entraTenantId)
|
||||
->where('slug', $entraTenantId)
|
||||
->first();
|
||||
|
||||
if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) {
|
||||
if ($existingTenant instanceof ManagedEnvironment && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id === (int) $this->workspace->getKey()) {
|
||||
if ($existingTenant instanceof ManagedEnvironment && (int) $existingTenant->workspace_id === (int) $this->workspace->getKey()) {
|
||||
$tenant = $existingTenant;
|
||||
} else {
|
||||
throw $exception;
|
||||
@ -3459,13 +3529,13 @@ public function identifyManagedTenant(array $data): void
|
||||
source: 'manual',
|
||||
);
|
||||
|
||||
$ownerCount = TenantMembership::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
$ownerCount = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->count();
|
||||
|
||||
if ($ownerCount === 0) {
|
||||
throw new RuntimeException('Tenant must have at least one owner.');
|
||||
throw new RuntimeException('ManagedEnvironment must have at least one owner.');
|
||||
}
|
||||
|
||||
$this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant);
|
||||
@ -3477,7 +3547,7 @@ public function identifyManagedTenant(array $data): void
|
||||
preferredDraft: $this->onboardingSession,
|
||||
expectedVersion: $this->expectedDraftVersion(),
|
||||
mutator: function (TenantOnboardingSession $draft) use ($tenant, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes): void {
|
||||
$draft->tenant_id = (int) $tenant->getKey();
|
||||
$draft->managed_environment_id = (int) $tenant->getKey();
|
||||
$draft->current_step = 'identify';
|
||||
$draft->state = array_merge($draft->state ?? [], [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
@ -3571,7 +3641,7 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->whereKey($providerConnectionId)
|
||||
->first();
|
||||
|
||||
@ -3651,7 +3721,7 @@ public function createProviderConnection(array $data): void
|
||||
|
||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -3675,10 +3745,10 @@ public function createProviderConnection(array $data): void
|
||||
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: (string) $tenant->tenant_id,
|
||||
scopeIdentifier: (string) $tenant->managed_environment_id,
|
||||
scopeDisplayName: $displayName,
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => (string) $tenant->tenant_id,
|
||||
'microsoft_tenant_id' => (string) $tenant->managed_environment_id,
|
||||
],
|
||||
);
|
||||
|
||||
@ -3702,17 +3772,17 @@ public function createProviderConnection(array $data): void
|
||||
/** @var ProviderConnection $connection */
|
||||
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection {
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', (string) $tenant->tenant_id)
|
||||
->where('entra_tenant_id', (string) $tenant->managed_environment_id)
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
$connection = ProviderConnection::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'entra_tenant_id' => (string) $tenant->managed_environment_id,
|
||||
'display_name' => $displayName,
|
||||
'is_enabled' => true,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
@ -3894,7 +3964,7 @@ public function startVerification(): void
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -4075,7 +4145,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
|
||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -4503,7 +4573,7 @@ private function verificationIsBlocked(): bool
|
||||
|
||||
private function canCompleteOnboarding(): bool
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
if (! $this->managedTenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -4570,15 +4640,17 @@ private function completionSummaryEntitlementSummary(): string
|
||||
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
|
||||
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
|
||||
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
|
||||
$commercialSourceLabel = $this->completionSummaryCommercialSourceLabel($decision);
|
||||
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
|
||||
|
||||
return sprintf(
|
||||
'%s - %s - %d active of %d allowed (%s)',
|
||||
'%s - %s - %d active of %d allowed (%s, %s)',
|
||||
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
|
||||
$stateLabel,
|
||||
$currentUsage,
|
||||
$effectiveValue,
|
||||
$sourceLabel,
|
||||
$commercialSourceLabel,
|
||||
);
|
||||
}
|
||||
|
||||
@ -4640,6 +4712,16 @@ private function completionSummaryEntitlementSourceLabel(array $decision): strin
|
||||
: 'plan profile default';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decision
|
||||
*/
|
||||
private function completionSummaryCommercialSourceLabel(array $decision): string
|
||||
{
|
||||
return ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION
|
||||
? 'subscription-backed'
|
||||
: 'fallback-backed';
|
||||
}
|
||||
|
||||
private function completionActionTooltip(): ?string
|
||||
{
|
||||
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
|
||||
@ -4657,7 +4739,7 @@ private function completionSummaryTenantLine(): string
|
||||
{
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
@ -4671,7 +4753,7 @@ private function completionSummaryConnectionLabel(): string
|
||||
{
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
@ -4694,7 +4776,7 @@ private function completionSummaryConnectionLabel(): string
|
||||
|
||||
private function completionSummaryConnectionDetail(): string
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
if (! $this->managedTenant instanceof ManagedEnvironment) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -5031,7 +5113,7 @@ public function completeOnboarding(): void
|
||||
|
||||
$tenant = $tenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -5052,7 +5134,7 @@ public function completeOnboarding(): void
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $user): void {
|
||||
$tenant->update(['status' => Tenant::STATUS_ACTIVE]);
|
||||
$tenant->update(['status' => ManagedEnvironment::STATUS_ACTIVE]);
|
||||
|
||||
$this->setOnboardingSession($this->mutationService()->mutate(
|
||||
draft: $this->onboardingSession,
|
||||
@ -5068,8 +5150,8 @@ public function completeOnboarding(): void
|
||||
} catch (OnboardingDraftConflictException) {
|
||||
$tenant->refresh();
|
||||
|
||||
if ($tenant->status === Tenant::STATUS_ACTIVE) {
|
||||
$tenant->update(['status' => Tenant::STATUS_ONBOARDING]);
|
||||
if ($tenant->status === ManagedEnvironment::STATUS_ACTIVE) {
|
||||
$tenant->update(['status' => ManagedEnvironment::STATUS_ONBOARDING]);
|
||||
}
|
||||
|
||||
$this->handleDraftConflict('Completing onboarding was blocked because the onboarding draft changed.');
|
||||
@ -5078,8 +5160,8 @@ public function completeOnboarding(): void
|
||||
} catch (OnboardingDraftImmutableException) {
|
||||
$tenant->refresh();
|
||||
|
||||
if ($tenant->status === Tenant::STATUS_ACTIVE) {
|
||||
$tenant->update(['status' => Tenant::STATUS_ONBOARDING]);
|
||||
if ($tenant->status === ManagedEnvironment::STATUS_ACTIVE) {
|
||||
$tenant->update(['status' => ManagedEnvironment::STATUS_ONBOARDING]);
|
||||
}
|
||||
|
||||
$this->handleImmutableDraft('Completing onboarding was blocked because the onboarding draft is no longer editable.');
|
||||
@ -5127,7 +5209,7 @@ public function completeOnboarding(): void
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
private function verificationRun(): ?OperationRun
|
||||
@ -5191,7 +5273,7 @@ private function verificationAssistVisibility(): array
|
||||
$user = $this->currentUser();
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
return $this->hiddenVerificationAssistVisibility();
|
||||
}
|
||||
|
||||
@ -5232,7 +5314,7 @@ private function verificationAssistViewModel(): array
|
||||
$user = $this->currentUser();
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -5324,14 +5406,14 @@ private function inlineEditSelectedConnectionFill(int $providerConnectionId): ar
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
if (! $this->managedTenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->with('credential')
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $this->managedTenant->getKey())
|
||||
->where('managed_environment_id', (int) $this->managedTenant->getKey())
|
||||
->whereKey($providerConnectionId)
|
||||
->first();
|
||||
|
||||
@ -5363,14 +5445,14 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
if (! $this->managedTenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->with('credential')
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $this->managedTenant->getKey())
|
||||
->where('managed_environment_id', (int) $this->managedTenant->getKey())
|
||||
->whereKey($providerConnectionId)
|
||||
->first();
|
||||
|
||||
@ -5558,11 +5640,11 @@ private function bootstrapOperationOptions(): array
|
||||
->all();
|
||||
}
|
||||
|
||||
private function resolveDefaultProviderConnectionId(Tenant $tenant): ?int
|
||||
private function resolveDefaultProviderConnectionId(ManagedEnvironment $tenant): ?int
|
||||
{
|
||||
$id = ProviderConnection::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('is_default', true)
|
||||
->orderByDesc('id')
|
||||
->value('id');
|
||||
@ -5573,14 +5655,14 @@ private function resolveDefaultProviderConnectionId(Tenant $tenant): ?int
|
||||
|
||||
$fallback = ProviderConnection::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->orderByDesc('id')
|
||||
->value('id');
|
||||
|
||||
return is_int($fallback) ? $fallback : null;
|
||||
}
|
||||
|
||||
private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderConnection
|
||||
private function resolveSelectedProviderConnection(ManagedEnvironment $tenant): ?ProviderConnection
|
||||
{
|
||||
$providerConnectionId = $this->selectedProviderConnectionId;
|
||||
|
||||
@ -5599,7 +5681,7 @@ private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderCon
|
||||
|
||||
return ProviderConnection::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->whereKey($providerConnectionId)
|
||||
->first();
|
||||
}
|
||||
@ -5617,7 +5699,7 @@ private function resolvePersistedProviderConnectionId(mixed $providerConnectionI
|
||||
$tenantId = $this->managedTenant?->getKey();
|
||||
|
||||
if (! is_int($tenantId) && $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$tenantId = is_numeric($this->onboardingSession->tenant_id) ? (int) $this->onboardingSession->tenant_id : null;
|
||||
$tenantId = is_numeric($this->onboardingSession->managed_environment_id) ? (int) $this->onboardingSession->managed_environment_id : null;
|
||||
}
|
||||
|
||||
if (! is_int($tenantId)) {
|
||||
@ -5627,7 +5709,7 @@ private function resolvePersistedProviderConnectionId(mixed $providerConnectionI
|
||||
$exists = ProviderConnection::query()
|
||||
->whereKey($providerConnectionId)
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->exists();
|
||||
|
||||
return $exists ? $providerConnectionId : null;
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
@ -48,26 +48,26 @@ public function mount(Workspace $workspace): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
* @return Collection<int, ManagedEnvironment>
|
||||
*/
|
||||
public function getTenants(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
return ManagedEnvironment::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenantMemberships()
|
||||
->pluck('tenant_id');
|
||||
->pluck('managed_environment_id');
|
||||
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->whereIn('id', $tenantIds)
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(function (Tenant $tenant) use ($user): bool {
|
||||
->filter(function (ManagedEnvironment $tenant) use ($user): bool {
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||
@ -81,7 +81,7 @@ public function getTenants(): Collection
|
||||
|
||||
public function goToChooseTenant(): void
|
||||
{
|
||||
$this->redirect(ChooseTenant::getUrl());
|
||||
$this->redirect(route('admin.workspace.managed-tenants.index', ['workspace' => $this->workspace]));
|
||||
}
|
||||
|
||||
public function openTenant(int $tenantId): void
|
||||
@ -92,13 +92,13 @@ public function openTenant(int $tenantId): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -106,6 +106,8 @@ public function openTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||
$this->redirect(
|
||||
\App\Filament\Pages\TenantDashboard::getUrl(tenant: $tenant)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -133,13 +133,13 @@ public static function getEloquentQuery(): Builder
|
||||
->when(
|
||||
$user instanceof User,
|
||||
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
|
||||
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
|
||||
->orWhereNull('tenant_id');
|
||||
$q->whereIn('managed_environment_id', $user->tenantMemberships()->select('managed_environment_id'))
|
||||
->orWhereNull('managed_environment_id');
|
||||
}),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
$activeTenant instanceof ManagedEnvironment,
|
||||
fn (Builder $query): Builder => $query->where('managed_environment_id', (int) $activeTenant->getKey()),
|
||||
)
|
||||
->latest('id');
|
||||
}
|
||||
@ -169,7 +169,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('tenant.name')
|
||||
->label('Tenant'),
|
||||
->label('ManagedEnvironment'),
|
||||
TextEntry::make('rule.name')
|
||||
->label('Rule')
|
||||
->placeholder('—'),
|
||||
@ -230,7 +230,7 @@ public static function table(Table $table): Table
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->searchable(),
|
||||
TextColumn::make('event_type')
|
||||
->label('Event')
|
||||
@ -257,12 +257,12 @@ public static function table(Table $table): Table
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
if ($activeTenant instanceof ManagedEnvironment) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
@ -275,7 +275,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
@ -283,7 +283,7 @@ public static function table(Table $table): Table
|
||||
->default(function (): ?string {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use App\Filament\Resources\AlertRuleResource\Pages;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
@ -434,9 +434,9 @@ private static function tenantOptions(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('status', 'active')
|
||||
->where('lifecycle_status', ManagedEnvironment::STATUS_ACTIVE)
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
|
||||
@ -5,11 +5,12 @@
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Rules\SupportedPolicyTypesRule;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -68,6 +69,7 @@ class BackupScheduleResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = BackupSchedule::class;
|
||||
|
||||
@ -79,10 +81,6 @@ class BackupScheduleResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
@ -92,7 +90,7 @@ public static function canViewAny(): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -108,7 +106,7 @@ public static function canView(Model $record): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -120,7 +118,7 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
if ($record instanceof BackupSchedule) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
return (int) $record->managed_environment_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -132,7 +130,7 @@ public static function canCreate(): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -148,7 +146,7 @@ public static function canEdit(Model $record): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -164,7 +162,7 @@ public static function canDelete(Model $record): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -180,7 +178,7 @@ public static function canDeleteAny(): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -440,7 +438,7 @@ public static function table(Table $table): Table
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
@ -511,7 +509,7 @@ public static function table(Table $table): Table
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
@ -590,7 +588,7 @@ public static function table(Table $table): Table
|
||||
|
||||
$record->restore();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
if ($record->tenant instanceof ManagedEnvironment) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.restored',
|
||||
@ -633,7 +631,7 @@ public static function table(Table $table): Table
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
if ($record->tenant instanceof ManagedEnvironment) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.archived',
|
||||
@ -685,7 +683,7 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
if ($record->tenant instanceof ManagedEnvironment) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.force_deleted',
|
||||
@ -728,7 +726,7 @@ public static function table(Table $table): Table
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
@ -825,7 +823,7 @@ public static function table(Table $table): Table
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
@ -1062,7 +1060,7 @@ public static function ensurePolicyTypes(array $data): array
|
||||
|
||||
public static function assignTenant(array $data): array
|
||||
{
|
||||
$data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
$data['managed_environment_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
@ -37,12 +37,12 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('managed_environment_id', ManagedEnvironment::currentOrFail()->getKey()))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->recordUrl(function (OperationRun $record): string {
|
||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||
$tenant = Tenant::currentOrFail();
|
||||
$tenant = ManagedEnvironment::currentOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
@ -106,7 +106,7 @@ private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
||||
|
||||
$resolvedRecord = $this->getOwnerRecord()
|
||||
->operationRuns()
|
||||
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
||||
->where('managed_environment_id', ManagedEnvironment::currentOrFail()->getKey())
|
||||
->whereKey($recordId)
|
||||
->first();
|
||||
|
||||
|
||||
@ -4,13 +4,14 @@
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\BackupSetResource\Pages;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
use App\Jobs\BulkBackupSetForceDeleteJob;
|
||||
use App\Jobs\BulkBackupSetRestoreJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -62,6 +63,7 @@ class BackupSetResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = BackupSet::class;
|
||||
|
||||
@ -73,10 +75,6 @@ class BackupSetResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
@ -96,7 +94,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -112,7 +110,7 @@ public static function canCreate(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -352,7 +350,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -369,7 +367,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
@ -422,7 +420,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -439,7 +437,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
@ -507,7 +505,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -524,7 +522,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
@ -651,7 +649,7 @@ private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextE
|
||||
*/
|
||||
public static function createBackupSet(array $data): BackupSet
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
/** @var ManagedEnvironment $tenant */
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
/** @var BackupService $service */
|
||||
@ -846,7 +844,7 @@ private static function backupHealthContinuityAssessment(BackupSet $record): ?Te
|
||||
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
$assessment = $resolver->assess((int) $record->tenant_id);
|
||||
$assessment = $resolver->assess((int) $record->managed_environment_id);
|
||||
|
||||
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
|
||||
return null;
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
@ -72,7 +72,7 @@ private function restoreAction(): Action
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
if ($record->tenant instanceof ManagedEnvironment) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.restored',
|
||||
@ -113,7 +113,7 @@ private function archiveAction(): Action
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
if ($record->tenant instanceof ManagedEnvironment) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.deleted',
|
||||
@ -162,7 +162,7 @@ private function forceDeleteAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
if ($record->tenant instanceof ManagedEnvironment) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.force_deleted',
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -122,12 +122,12 @@ public function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$tenant = $backupSet->tenant ?? ManagedEnvironment::current();
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->managed_environment_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -201,12 +201,12 @@ public function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$tenant = $backupSet->tenant ?? ManagedEnvironment::current();
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->managed_environment_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -477,7 +477,7 @@ private function backupItemInspectUrl(BackupItem $record): ?string
|
||||
|
||||
$resolvedRecord = $backupSet->items()
|
||||
->with(['policy', 'policyVersion', 'policyVersion.policy'])
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->where('managed_environment_id', (int) $backupSet->managed_environment_id)
|
||||
->whereKey($resolvedId)
|
||||
->first();
|
||||
|
||||
@ -485,9 +485,9 @@ private function backupItemInspectUrl(BackupItem $record): ?string
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
$tenant = $backupSet->tenant ?? ManagedEnvironment::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -516,7 +516,7 @@ private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $rec
|
||||
}
|
||||
|
||||
$resolvedId = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->where('managed_environment_id', (int) $backupSet->managed_environment_id)
|
||||
->whereKey($recordId)
|
||||
->value('id');
|
||||
|
||||
@ -545,7 +545,7 @@ private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, a
|
||||
}
|
||||
|
||||
$resolvedIds = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->where('managed_environment_id', (int) $backupSet->managed_environment_id)
|
||||
->whereIn('id', $requestedIds)
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
@ -968,7 +968,7 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||
$tenantIds = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->pluck('tenant_id')
|
||||
->pluck('managed_environment_id')
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
@ -977,11 +977,11 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::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));
|
||||
->contains(fn (ManagedEnvironment $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -77,7 +77,7 @@ private function captureAction(): Action
|
||||
->modalDescription($modalDescription)
|
||||
->form([
|
||||
Select::make('source_tenant_id')
|
||||
->label('Source Tenant')
|
||||
->label('Source ManagedEnvironment')
|
||||
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
||||
->required()
|
||||
->searchable(),
|
||||
@ -91,9 +91,9 @@ private function captureAction(): Action
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
|
||||
$sourceTenant = ManagedEnvironment::query()->find((int) $data['source_tenant_id']);
|
||||
|
||||
if (! $sourceTenant instanceof Tenant) {
|
||||
if (! $sourceTenant instanceof ManagedEnvironment) {
|
||||
Notification::make()
|
||||
->title('Source tenant not found')
|
||||
->danger()
|
||||
@ -188,7 +188,7 @@ private function compareNowAction(): Action
|
||||
->modalDescription($modalDescription)
|
||||
->form([
|
||||
Select::make('target_tenant_id')
|
||||
->label('Target Tenant')
|
||||
->label('Target ManagedEnvironment')
|
||||
->options(fn (): array => $this->getEligibleCompareTenantOptions())
|
||||
->required()
|
||||
->searchable(),
|
||||
@ -204,9 +204,9 @@ private function compareNowAction(): Action
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
$targetTenant = Tenant::query()->find((int) $data['target_tenant_id']);
|
||||
$targetTenant = ManagedEnvironment::query()->find((int) $data['target_tenant_id']);
|
||||
|
||||
if (! $targetTenant instanceof Tenant || (int) $targetTenant->workspace_id !== (int) $profile->workspace_id) {
|
||||
if (! $targetTenant instanceof ManagedEnvironment || (int) $targetTenant->workspace_id !== (int) $profile->workspace_id) {
|
||||
Notification::make()
|
||||
->title('Target tenant not found')
|
||||
->danger()
|
||||
@ -217,13 +217,13 @@ private function compareNowAction(): Action
|
||||
|
||||
$assignment = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('tenant_id', (int) $targetTenant->getKey())
|
||||
->where('managed_environment_id', (int) $targetTenant->getKey())
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->first();
|
||||
|
||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||
Notification::make()
|
||||
->title('Tenant not assigned')
|
||||
->title('ManagedEnvironment not assigned')
|
||||
->body('This tenant is not assigned to this baseline profile.')
|
||||
->warning()
|
||||
->send();
|
||||
@ -384,7 +384,7 @@ private function getWorkspaceTenantOptions(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
@ -408,7 +408,7 @@ private function getEligibleCompareTenantOptions(): array
|
||||
$tenantIds = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->pluck('tenant_id')
|
||||
->pluck('managed_environment_id')
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
@ -419,14 +419,14 @@ private function getEligibleCompareTenantOptions(): array
|
||||
|
||||
$options = [];
|
||||
|
||||
$tenants = Tenant::query()
|
||||
$tenants = ManagedEnvironment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->whereIn('id', $tenantIds)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -510,7 +510,7 @@ private function visibleAssignedTenantCount(BaselineProfile $profile): int
|
||||
$tenantIds = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->pluck('tenant_id')
|
||||
->pluck('managed_environment_id')
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
@ -519,11 +519,11 @@ private function visibleAssignedTenantCount(BaselineProfile $profile): int
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->whereIn('id', $tenantIds)
|
||||
->get(['id'])
|
||||
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||
->filter(fn (ManagedEnvironment $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
@ -27,7 +27,7 @@ class BaselineTenantAssignmentsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'tenantAssignments';
|
||||
|
||||
protected static ?string $title = 'Tenant assignments';
|
||||
protected static ?string $title = 'ManagedEnvironment assignments';
|
||||
|
||||
/**
|
||||
* @var array<int, array{baseline_profile_id:int, baseline_profile_name:string}>|null
|
||||
@ -51,7 +51,7 @@ public function table(Table $table): Table
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('assignedByUser.name')
|
||||
->label('Assigned by')
|
||||
@ -78,12 +78,12 @@ public function table(Table $table): Table
|
||||
private function assignTenantAction(): Action
|
||||
{
|
||||
return Action::make('assign')
|
||||
->label('Assign Tenant')
|
||||
->label('Assign ManagedEnvironment')
|
||||
->icon('heroicon-o-plus')
|
||||
->visible(fn (): bool => $this->hasManageCapability())
|
||||
->form([
|
||||
Select::make('tenant_id')
|
||||
->label('Tenant')
|
||||
Select::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(fn (): array => $this->getTenantOptions())
|
||||
->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries()))
|
||||
->required()
|
||||
@ -103,18 +103,18 @@ private function assignTenantAction(): Action
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getOwnerRecord();
|
||||
$tenantId = (int) $data['tenant_id'];
|
||||
$tenantId = (int) $data['managed_environment_id'];
|
||||
|
||||
$existing = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if ($existing instanceof BaselineTenantAssignment) {
|
||||
$assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant already assigned')
|
||||
->title('ManagedEnvironment already assigned')
|
||||
->body($assignedBaselineName === null
|
||||
? 'This tenant already has a baseline assignment in this workspace.'
|
||||
: "This tenant is already assigned to baseline: {$assignedBaselineName}.")
|
||||
@ -126,7 +126,7 @@ private function assignTenantAction(): Action
|
||||
|
||||
$assignment = BaselineTenantAssignment::create([
|
||||
'workspace_id' => (int) $profile->workspace_id,
|
||||
'tenant_id' => $tenantId,
|
||||
'managed_environment_id' => $tenantId,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'assigned_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
@ -134,7 +134,7 @@ private function assignTenantAction(): Action
|
||||
$this->auditAssignment($profile, $assignment, $user, 'created');
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant assigned')
|
||||
->title('ManagedEnvironment assigned')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
@ -190,11 +190,11 @@ private function getTenantOptions(): array
|
||||
|
||||
$assignmentSummaries = $this->getTenantAssignmentSummaries();
|
||||
|
||||
return Tenant::query()
|
||||
return ManagedEnvironment::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->mapWithKeys(function (Tenant $tenant) use ($assignmentSummaries): array {
|
||||
->mapWithKeys(function (ManagedEnvironment $tenant) use ($assignmentSummaries): array {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$assignmentSummary = $assignmentSummaries[$tenantId] ?? null;
|
||||
|
||||
@ -220,12 +220,12 @@ private function getTenantAssignmentSummaries(): array
|
||||
$this->tenantAssignmentSummaries = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->with('baselineProfile:id,name')
|
||||
->get(['tenant_id', 'baseline_profile_id'])
|
||||
->get(['managed_environment_id', 'baseline_profile_id'])
|
||||
->mapWithKeys(function (BaselineTenantAssignment $assignment): array {
|
||||
$baselineProfile = $assignment->baselineProfile;
|
||||
|
||||
return [
|
||||
(int) $assignment->tenant_id => [
|
||||
(int) $assignment->managed_environment_id => [
|
||||
'baseline_profile_id' => (int) $assignment->baseline_profile_id,
|
||||
'baseline_profile_name' => $baselineProfile instanceof BaselineProfile
|
||||
? (string) $baselineProfile->name
|
||||
@ -242,7 +242,7 @@ private function getTenantAssignmentSummaries(): array
|
||||
* @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary
|
||||
*/
|
||||
private function formatTenantOptionLabel(
|
||||
Tenant $tenant,
|
||||
ManagedEnvironment $tenant,
|
||||
?array $assignmentSummary,
|
||||
): string {
|
||||
$tenantName = (string) $tenant->name;
|
||||
@ -282,7 +282,7 @@ private function auditAssignment(
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find($assignment->tenant_id);
|
||||
$tenant = ManagedEnvironment::query()->find($assignment->managed_environment_id);
|
||||
|
||||
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||
|
||||
@ -292,8 +292,8 @@ private function auditAssignment(
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_profile_name' => (string) $profile->name,
|
||||
'tenant_id' => (int) $assignment->tenant_id,
|
||||
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
|
||||
'managed_environment_id' => (int) $assignment->managed_environment_id,
|
||||
'tenant_name' => $tenant instanceof ManagedEnvironment ? (string) $tenant->display_name : '—',
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'baseline_profile',
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
@ -204,7 +204,7 @@ public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
|
||||
public static function getGlobalSearchResultUrl(Model $record): string
|
||||
{
|
||||
$tenant = $record instanceof EntraGroup && $record->tenant instanceof Tenant
|
||||
$tenant = $record instanceof EntraGroup && $record->tenant instanceof ManagedEnvironment
|
||||
? $record->tenant
|
||||
: static::panelTenantContext();
|
||||
|
||||
@ -225,7 +225,7 @@ public static function getPages(): array
|
||||
public static function scopedUrl(
|
||||
string $page = 'index',
|
||||
array $parameters = [],
|
||||
?Tenant $tenant = null,
|
||||
?ManagedEnvironment $tenant = null,
|
||||
?string $panel = null,
|
||||
): string {
|
||||
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Directory\EntraGroupSyncService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -30,7 +30,7 @@ public function mount(): void
|
||||
|
||||
if (
|
||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
|
||||
&& ! EntraGroupResource::panelTenantContext() instanceof ManagedEnvironment
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
@ -47,7 +47,7 @@ protected function getHeaderActions(): array
|
||||
->label('Operations')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => OperationRunLinks::index($tenant))
|
||||
->visible(fn (): bool => $tenant instanceof Tenant),
|
||||
->visible(fn (): bool => $tenant instanceof ManagedEnvironment),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
@ -57,7 +57,7 @@ protected function getHeaderActions(): array
|
||||
$user = auth()->user();
|
||||
$tenant = EntraGroupResource::panelTenantContext();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -27,16 +27,16 @@ protected function authorizeAccess(): void
|
||||
|
||||
if (
|
||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||
&& ! $tenant instanceof Tenant
|
||||
&& ! $tenant instanceof ManagedEnvironment
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof EntraGroup) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $record instanceof EntraGroup) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@ -6,12 +6,13 @@
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -64,6 +65,7 @@ class EvidenceSnapshotResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = EvidenceSnapshot::class;
|
||||
|
||||
@ -86,7 +88,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -102,7 +104,7 @@ public static function canView(Model $record): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -111,7 +113,7 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
return ! $record instanceof EvidenceSnapshot
|
||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||
|| ((int) $record->managed_environment_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
@ -167,16 +169,19 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('tenant.name')->label('ManagedEnvironment'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation')
|
||||
->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'),
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Summary')
|
||||
@ -222,6 +227,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Raw summary JSON')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(4),
|
||||
@ -236,7 +242,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'operation_run',
|
||||
label: 'Operation',
|
||||
@ -254,13 +260,19 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
||||
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof ManagedEnvironment) {
|
||||
$packUrl = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||
|
||||
if (static::isCustomerWorkspaceFlow()) {
|
||||
$packUrl = static::appendQuery($packUrl, static::customerWorkspaceContextQuery());
|
||||
}
|
||||
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
value: sprintf('#%d', (int) $pack->getKey()),
|
||||
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
||||
targetUrl: $packUrl,
|
||||
targetKind: 'direct_record',
|
||||
priority: 20,
|
||||
actionLabel: 'View review pack',
|
||||
@ -268,7 +280,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
if ($record->tenant instanceof ManagedEnvironment) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'customer_review_workspace',
|
||||
label: 'Customer workspace',
|
||||
@ -285,6 +297,36 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
{
|
||||
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function customerWorkspaceContextQuery(): array
|
||||
{
|
||||
return array_filter([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => request()->query('review_id'),
|
||||
'interpretation_version' => request()->query('interpretation_version'),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@ -740,10 +782,10 @@ private static function stringifySummaryValue(mixed $value): string
|
||||
*/
|
||||
public static function executeGeneration(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
||||
|
||||
return;
|
||||
|
||||
@ -5,8 +5,13 @@
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
@ -20,6 +25,13 @@ class ViewEvidenceSnapshot extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EvidenceSnapshotResource::class;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$this->auditCustomerWorkspaceProofOpen();
|
||||
}
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||
@ -27,6 +39,10 @@ protected function resolveRecord(int|string $key): Model
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||
|
||||
@ -90,4 +106,44 @@ protected function getHeaderActions(): array
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
private function auditCustomerWorkspaceProofOpen(): void
|
||||
{
|
||||
if (! EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = $this->record;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof EvidenceSnapshot || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotOpened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'evidence_snapshot_id' => (int) $record->getKey(),
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => request()->query('review_id'),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => request()->query('interpretation_version'),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $record->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $record->getKey()),
|
||||
tenant: $tenant,
|
||||
operationRunId: $record->operation_run_id !== null ? (int) $record->operation_run_id : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,12 @@
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionEvidenceReference;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -53,6 +54,7 @@ class FindingExceptionResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = FindingException::class;
|
||||
|
||||
@ -70,10 +72,6 @@ class FindingExceptionResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
@ -82,7 +80,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -98,7 +96,7 @@ public static function canView(Model $record): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -107,7 +105,7 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
return ! $record instanceof FindingException
|
||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||
|| ((int) $record->managed_environment_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
@ -134,12 +132,12 @@ public static function exceptionStatsForCurrentTenant(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$counts = FindingException::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->selectRaw('count(*) as total')
|
||||
->selectRaw("count(*) filter (where status = 'active') as active")
|
||||
@ -191,7 +189,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->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('tenant.name')->label('ManagedEnvironment'),
|
||||
TextEntry::make('finding_summary')
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
||||
@ -264,13 +262,13 @@ public static function relatedContextEntries(FindingException $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if ($record->finding && $record->tenant instanceof Tenant) {
|
||||
if ($record->finding && $record->tenant instanceof ManagedEnvironment) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'finding',
|
||||
label: 'Finding',
|
||||
value: static::findingSummary($record),
|
||||
secondaryValue: 'Return to the linked finding detail.',
|
||||
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant),
|
||||
targetKind: 'direct_record',
|
||||
priority: 10,
|
||||
actionLabel: 'Open finding',
|
||||
@ -278,7 +276,7 @@ public static function relatedContextEntries(FindingException $record): array
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
||||
if ($record->tenant instanceof ManagedEnvironment && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'approval_queue',
|
||||
label: 'Approval queue',
|
||||
@ -428,7 +426,7 @@ public static function table(Table $table): Table
|
||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $record->tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -532,13 +530,13 @@ private static function tenantMemberOptions(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \App\Models\TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||
return \App\Models\ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'managed_environment_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
@ -608,7 +606,7 @@ private static function canManageRecord(FindingException $record): bool
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $record->tenant instanceof Tenant
|
||||
&& $record->tenant instanceof ManagedEnvironment
|
||||
&& $user->canAccessTenant($record->tenant)
|
||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||
}
|
||||
@ -643,12 +641,12 @@ private static function governanceWarningColor(FindingException $record): string
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
|
||||
public static function canAccessApprovalQueueForTenant(?ManagedEnvironment $tenant = null): bool
|
||||
{
|
||||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -665,11 +663,11 @@ public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null):
|
||||
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||
}
|
||||
|
||||
public static function approvalQueueUrl(?Tenant $tenant = null): ?string
|
||||
public static function approvalQueueUrl(?ManagedEnvironment $tenant = null): ?string
|
||||
{
|
||||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -6,10 +6,11 @@
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
@ -36,7 +37,18 @@ protected function getHeaderActions(): array
|
||||
$renewRule = GovernanceActionCatalog::rule('renew_exception');
|
||||
$revokeRule = GovernanceActionCatalog::rule('revoke_exception');
|
||||
|
||||
return [
|
||||
$actions = [];
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_decision_register')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
}
|
||||
|
||||
return array_merge($actions, [
|
||||
Action::make('renew_exception')
|
||||
->label($renewRule->canonicalLabel)
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -159,7 +171,18 @@ protected function getHeaderActions(): array
|
||||
|
||||
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
||||
}),
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->sourceSurface === 'governance.decision_register') {
|
||||
return 'Opened from the workspace decision register. Use the back action to return to the same register scope.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,13 +198,13 @@ private function tenantMemberOptions(): array
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \App\Models\TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||
return \App\Models\ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'managed_environment_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
@ -194,9 +217,14 @@ private function canManageRecord(): bool
|
||||
$user = auth()->user();
|
||||
|
||||
return $record instanceof FindingException
|
||||
&& $record->tenant instanceof Tenant
|
||||
&& $record->tenant instanceof ManagedEnvironment
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($record->tenant)
|
||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,13 +4,14 @@
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Filament\Support\NormalizedDiffSurface;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
@ -66,6 +67,7 @@ class FindingResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = Finding::class;
|
||||
|
||||
@ -77,10 +79,6 @@ class FindingResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
@ -110,7 +108,7 @@ public static function canViewAny(): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -127,7 +125,7 @@ public static function canView(Model $record): bool
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -140,7 +138,7 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
if ($record instanceof Finding) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey()
|
||||
return (int) $record->managed_environment_id === (int) $tenant->getKey()
|
||||
&& (int) $record->workspace_id === (int) $tenant->workspace_id;
|
||||
}
|
||||
|
||||
@ -679,17 +677,17 @@ private static function driftDiffUnavailableMessage(Finding $record): string
|
||||
/**
|
||||
* @return array{0: ?PolicyVersion, 1: ?PolicyVersion}
|
||||
*/
|
||||
private static function resolveDriftDiffVersions(Finding $record, Tenant $tenant): array
|
||||
private static function resolveDriftDiffVersions(Finding $record, ManagedEnvironment $tenant): array
|
||||
{
|
||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
|
||||
$baselineVersion = is_numeric($baselineId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||
? PolicyVersion::query()->where('managed_environment_id', $tenant->getKey())->find((int) $baselineId)
|
||||
: null;
|
||||
|
||||
$currentVersion = is_numeric($currentId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||
? PolicyVersion::query()->where('managed_environment_id', $tenant->getKey())->find((int) $currentId)
|
||||
: null;
|
||||
|
||||
return [$baselineVersion, $currentVersion];
|
||||
@ -974,7 +972,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -989,7 +987,7 @@ public static function table(Table $table): Table
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
@ -1057,7 +1055,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1076,7 +1074,7 @@ public static function table(Table $table): Table
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
@ -1149,7 +1147,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1166,7 +1164,7 @@ public static function table(Table $table): Table
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
@ -1228,7 +1226,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1245,7 +1243,7 @@ public static function table(Table $table): Table
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
@ -1369,9 +1367,9 @@ private static function findingRunNavigationContext(Finding $record): CanonicalN
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: 'Back to finding',
|
||||
backLinkUrl: static::getUrl('view', ['record' => $record], tenant: $tenant),
|
||||
filterPayload: $tenant instanceof Tenant ? [
|
||||
filterPayload: $tenant instanceof ManagedEnvironment ? [
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => (string) $tenant->getKey()],
|
||||
'managed_environment_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
@ -1418,7 +1416,7 @@ public static function triageAction(): Actions\Action
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding triaged',
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->triage($finding, $tenant, $user),
|
||||
callback: fn (Finding $finding, ManagedEnvironment $tenant, User $user): Finding => $workflow->triage($finding, $tenant, $user),
|
||||
);
|
||||
})
|
||||
)
|
||||
@ -1442,7 +1440,7 @@ public static function startProgressAction(): Actions\Action
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding moved to in progress',
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->startProgress($finding, $tenant, $user),
|
||||
callback: fn (Finding $finding, ManagedEnvironment $tenant, User $user): Finding => $workflow->startProgress($finding, $tenant, $user),
|
||||
);
|
||||
})
|
||||
)
|
||||
@ -1514,7 +1512,7 @@ public static function resolveAction(): Actions\Action
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: $rule->successTitle,
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
|
||||
callback: fn (Finding $finding, ManagedEnvironment $tenant, User $user): Finding => $workflow->resolve(
|
||||
$finding,
|
||||
$tenant,
|
||||
$user,
|
||||
@ -1555,7 +1553,7 @@ public static function closeAction(): Actions\Action
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: $rule->successTitle,
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close(
|
||||
callback: fn (Finding $finding, ManagedEnvironment $tenant, User $user): Finding => $workflow->close(
|
||||
$finding,
|
||||
$tenant,
|
||||
$user,
|
||||
@ -1760,7 +1758,7 @@ public static function reopenAction(): Actions\Action
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: $rule->successTitle,
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen(
|
||||
callback: fn (Finding $finding, ManagedEnvironment $tenant, User $user): Finding => $workflow->reopen(
|
||||
$finding,
|
||||
$tenant,
|
||||
$user,
|
||||
@ -1776,7 +1774,7 @@ public static function reopenAction(): Actions\Action
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(Finding, Tenant, User): Finding $callback
|
||||
* @param callable(Finding, ManagedEnvironment, User): Finding $callback
|
||||
*/
|
||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||
{
|
||||
@ -1785,11 +1783,11 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different tenant')
|
||||
->danger()
|
||||
@ -1837,11 +1835,11 @@ private static function runResponsibilityMutation(Finding $record, array $data,
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different tenant')
|
||||
->danger()
|
||||
@ -1906,7 +1904,7 @@ private static function runExceptionRequestMutation(Finding $record, array $data
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1942,7 +1940,7 @@ private static function runExceptionRenewalMutation(Finding $record, array $data
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1978,7 +1976,7 @@ private static function runExceptionRevocationMutation(Finding $record, array $d
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2074,7 +2072,7 @@ private static function resolveCurrentFindingExceptionOrFail(Finding $record): F
|
||||
return $exception;
|
||||
}
|
||||
|
||||
private static function findingExceptionViewUrl(\App\Models\FindingException $exception, Tenant $tenant): string
|
||||
private static function findingExceptionViewUrl(\App\Models\FindingException $exception, ManagedEnvironment $tenant): string
|
||||
{
|
||||
$panelId = Filament::getCurrentPanel()?->getId();
|
||||
|
||||
@ -2082,7 +2080,7 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
|
||||
}
|
||||
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2388,13 +2386,13 @@ private static function tenantMemberOptions(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||
return ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'managed_environment_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
@ -2408,14 +2406,14 @@ public static function findingStatsForCurrentTenant(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return ['open' => 0, 'overdue' => 0, 'high_severity' => 0, 'risk_accepted' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$now = now()->toDateTimeString();
|
||||
|
||||
$counts = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->selectRaw('count(*) as total')
|
||||
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as open")
|
||||
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') and due_at is not null and due_at < ? then 1 else 0 end) as overdue", [$now])
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -152,7 +152,7 @@ protected function getHeaderActions(): array
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@ -5,9 +5,10 @@
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -23,8 +24,10 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Panel;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -33,12 +36,14 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use UnitEnum;
|
||||
|
||||
class InventoryItemResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = InventoryItem::class;
|
||||
|
||||
@ -54,11 +59,39 @@ class InventoryItemResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function getRouteBaseName(?Panel $panel = null): string
|
||||
{
|
||||
$panel ??= Filament::getCurrentOrDefaultPanel();
|
||||
|
||||
if ($panel->getId() !== 'admin') {
|
||||
return parent::getRouteBaseName($panel);
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
return $panel->generateRouteName(
|
||||
(string) str(static::getSlug($panel))
|
||||
->replace('/', '.')
|
||||
->prepend('resources.'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function registerRoutes(Panel $panel, ?Closure $registerPageRoutes = null): void
|
||||
{
|
||||
if ($panel->getId() !== 'admin') {
|
||||
parent::registerRoutes($panel, $registerPageRoutes);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$registerPageRoutes ??= function () use ($panel): void {
|
||||
foreach (static::getPages() as $name => $page) {
|
||||
$page->registerRoute($panel)?->name($name);
|
||||
}
|
||||
};
|
||||
|
||||
Route::name('resources.')->group(fn () => static::routes($panel, $registerPageRoutes));
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
@ -76,7 +109,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -91,7 +124,7 @@ public static function canView(Model $record): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -106,7 +139,7 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
if ($record instanceof InventoryItem) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
return (int) $record->managed_environment_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -151,7 +184,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
@ -28,6 +28,7 @@
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Enums\Size;
|
||||
|
||||
class ListInventoryItems extends ListRecords
|
||||
@ -36,6 +37,8 @@ class ListInventoryItems extends ListRecords
|
||||
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::Full;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
@ -46,7 +49,7 @@ public function mount(): void
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
||||
}
|
||||
|
||||
@ -125,7 +128,7 @@ protected function getHeaderActions(): array
|
||||
->dehydrated()
|
||||
->rules(['boolean'])
|
||||
->columnSpanFull(),
|
||||
Hidden::make('tenant_id')
|
||||
Hidden::make('managed_environment_id')
|
||||
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
|
||||
->dehydrated(),
|
||||
])
|
||||
@ -136,7 +139,7 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -146,11 +149,11 @@ protected function getHeaderActions(): array
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestedTenantId = $data['tenant_id'] ?? null;
|
||||
$requestedTenantId = $data['managed_environment_id'] ?? null;
|
||||
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Not allowed')
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -19,7 +19,7 @@ public function mount(int|string $record): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
@ -175,8 +175,8 @@ public static function table(Table $table): Table
|
||||
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
Tables\Filters\SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(function (): array {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -185,7 +185,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
@ -193,7 +193,7 @@ public static function table(Table $table): Table
|
||||
->default(function (): ?string {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -278,7 +278,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this operation.';
|
||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||
$referencedTenantLifecycle = $record->tenant instanceof ManagedEnvironment
|
||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||
: null;
|
||||
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||
@ -520,7 +520,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
entries: [
|
||||
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||
$factory->keyFact('ManagedEnvironment scope', $record->managed_environment_id),
|
||||
],
|
||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
@ -620,7 +620,7 @@ private static function supportingGroups(
|
||||
$lifecycleItems = array_values(array_filter([
|
||||
$referencedTenantLifecycle !== null
|
||||
? $factory->keyFact(
|
||||
'Tenant lifecycle',
|
||||
'ManagedEnvironment lifecycle',
|
||||
$referencedTenantLifecycle->presentation->label,
|
||||
badge: $factory->statusBadge(
|
||||
$referencedTenantLifecycle->presentation->label,
|
||||
@ -631,7 +631,7 @@ private static function supportingGroups(
|
||||
)
|
||||
: null,
|
||||
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
||||
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
||||
? $factory->keyFact('ManagedEnvironment selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
||||
: null,
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
@ -1266,13 +1266,13 @@ private static function verificationReportViewData(OperationRun $record): array
|
||||
if ($changeIndicator !== null) {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
$previousRunUrl = $tenant instanceof Tenant
|
||||
$previousRunUrl = $tenant instanceof ManagedEnvironment
|
||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
||||
}
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
||||
->where('managed_environment_id', (int) ($record->managed_environment_id ?? 0))
|
||||
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
||||
->where('operation_run_id', (int) $record->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
@ -1605,7 +1605,7 @@ public static function restoreContinuation(OperationRun $record): ?array
|
||||
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
$canOpenRestore = $tenant instanceof Tenant
|
||||
$canOpenRestore = $tenant instanceof ManagedEnvironment
|
||||
&& $user instanceof User
|
||||
&& app(\App\Services\Auth\CapabilityResolver::class)->isMember($user, $tenant);
|
||||
|
||||
@ -1618,7 +1618,7 @@ public static function restoreContinuation(OperationRun $record): ?array
|
||||
'follow_up_required' => $attention->followUpRequired,
|
||||
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
|
||||
'link_url' => $canOpenRestore
|
||||
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant)
|
||||
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant)
|
||||
: null,
|
||||
'link_available' => $canOpenRestore,
|
||||
];
|
||||
@ -1657,6 +1657,21 @@ private static function targetScopeDisplay(OperationRun $record): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$scopeDisplayName = $targetScope['scope_display_name'] ?? null;
|
||||
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
|
||||
$scopeDisplayName = is_string($scopeDisplayName) ? trim($scopeDisplayName) : null;
|
||||
$scopeIdentifier = is_string($scopeIdentifier) ? trim($scopeIdentifier) : null;
|
||||
|
||||
if ($scopeDisplayName !== null && $scopeDisplayName !== '') {
|
||||
return $scopeIdentifier !== null && $scopeIdentifier !== '' && $scopeIdentifier !== $scopeDisplayName
|
||||
? "{$scopeDisplayName} ({$scopeIdentifier})"
|
||||
: $scopeDisplayName;
|
||||
}
|
||||
|
||||
if ($scopeIdentifier !== null && $scopeIdentifier !== '') {
|
||||
return $scopeIdentifier;
|
||||
}
|
||||
|
||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\PolicyResource\Pages;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Filament\Support\NormalizedSettingsSurface;
|
||||
@ -13,7 +14,7 @@
|
||||
use App\Jobs\BulkPolicyUnignoreJob;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
@ -61,6 +62,7 @@ class PolicyResource extends Resource
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = Policy::class;
|
||||
|
||||
@ -72,12 +74,18 @@ class PolicyResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return static::text('common.policy');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return static::text('common.policies');
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
@ -86,7 +94,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -100,7 +108,7 @@ public static function canViewAny(): bool
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync policies.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
@ -112,17 +120,17 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label('Sync from Intune')
|
||||
->label(static::text('resource.sync_action_primary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Sync policies from Intune')
|
||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
||||
->modalHeading(static::text('resource.sync_modal_heading'))
|
||||
->modalDescription(static::text('resource.sync_modal_description').' '.static::text('common.source_microsoft_intune'))
|
||||
->action(function (Pages\ListPolicies $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -150,7 +158,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -165,14 +173,14 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to sync policies.')
|
||||
->tooltip(static::text('resource.sync_permission_tooltip'))
|
||||
->apply();
|
||||
}
|
||||
|
||||
@ -185,16 +193,31 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Policy Details')
|
||||
Section::make(static::text('resource.details_section'))
|
||||
->schema([
|
||||
TextEntry::make('display_name')->label('Policy'),
|
||||
TextEntry::make('policy_type')->label('Type'),
|
||||
TextEntry::make('platform'),
|
||||
TextEntry::make('external_id')->label('External ID'),
|
||||
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
||||
TextEntry::make('created_at')->since(),
|
||||
TextEntry::make('display_name')->label(static::text('common.policy')),
|
||||
TextEntry::make('policy_type')->label(static::text('common.type')),
|
||||
TextEntry::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
TextEntry::make('visibility_state')
|
||||
->label(static::text('common.visibility'))
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->helperText(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||
? static::text('resource.visibility_source_unavailable_backup_items')
|
||||
: null),
|
||||
TextEntry::make('external_id')->label(static::text('common.external_id')),
|
||||
TextEntry::make('last_synced_at')->dateTime()->label(static::text('common.last_synced')),
|
||||
TextEntry::make('created_at')->since()->label(static::text('common.created')),
|
||||
TextEntry::make('latest_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||
@ -211,8 +234,8 @@ public static function infolist(Schema $schema): Schema
|
||||
$status = $meta['original_status'] ?? null;
|
||||
|
||||
return sprintf(
|
||||
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
||||
$status ?? 'an error'
|
||||
static::text('resource.snapshot_metadata_only_helper'),
|
||||
$status ?? static::text('resource.graph_error_fallback')
|
||||
);
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
@ -225,7 +248,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->activeTab(1)
|
||||
->persistTabInQueryString()
|
||||
->tabs([
|
||||
Tab::make('General')
|
||||
Tab::make(static::text('resource.tab_general'))
|
||||
->id('general')
|
||||
->schema([
|
||||
ViewEntry::make('policy_general')
|
||||
@ -236,7 +259,7 @@ public static function infolist(Schema $schema): Schema
|
||||
}),
|
||||
])
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
Tab::make('Settings')
|
||||
Tab::make(static::text('common.settings'))
|
||||
->id('settings')
|
||||
->schema([
|
||||
ViewEntry::make('settings')
|
||||
@ -248,12 +271,12 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
|
||||
TextEntry::make('no_settings_available')
|
||||
->label('Settings')
|
||||
->state('No policy snapshot available yet.')
|
||||
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
|
||||
->label(static::text('common.settings'))
|
||||
->state(static::text('resource.settings_empty_state'))
|
||||
->helperText(static::text('resource.settings_empty_state_helper'))
|
||||
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
||||
]),
|
||||
Tab::make('JSON')
|
||||
Tab::make(static::text('resource.tab_json'))
|
||||
->id('json')
|
||||
->schema([
|
||||
ViewEntry::make('snapshot_json')
|
||||
@ -261,7 +284,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(fn (Policy $record) => static::latestSnapshot($record))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('snapshot_size')
|
||||
->label('Payload Size')
|
||||
->label(static::text('resource.payload_size'))
|
||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||
->formatStateUsing(function ($state) {
|
||||
if ($state > 512000) {
|
||||
@ -269,7 +292,7 @@ public static function infolist(Schema $schema): Schema
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
||||
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||
</span>';
|
||||
}
|
||||
|
||||
@ -284,7 +307,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
||||
|
||||
// Legacy layout (kept for fallback if tabs are disabled)
|
||||
Section::make('Settings')
|
||||
Section::make(static::text('common.settings'))
|
||||
->schema([
|
||||
ViewEntry::make('settings')
|
||||
->label('')
|
||||
@ -298,7 +321,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return ! static::usesTabbedLayout($record);
|
||||
}),
|
||||
|
||||
Section::make('Policy Snapshot (JSON)')
|
||||
Section::make(static::text('resource.snapshot_json_section'))
|
||||
->schema([
|
||||
ViewEntry::make('snapshot_json')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
@ -306,7 +329,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
|
||||
TextEntry::make('snapshot_size')
|
||||
->label('Payload Size')
|
||||
->label(static::text('resource.payload_size'))
|
||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||
->formatStateUsing(function ($state) {
|
||||
if ($state > 512000) {
|
||||
@ -314,7 +337,7 @@ public static function infolist(Schema $schema): Schema
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
||||
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||
</span>';
|
||||
}
|
||||
|
||||
@ -336,11 +359,6 @@ public static function infolist(Schema $schema): Schema
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(function (Builder $query) {
|
||||
// Quick-Workaround: Hide policies not synced in last 7 days
|
||||
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
|
||||
$query->where('last_synced_at', '>', now()->subDays(7));
|
||||
})
|
||||
->defaultSort('display_name')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
@ -349,24 +367,36 @@ public static function table(Table $table): Table
|
||||
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('visibility_state')
|
||||
->label(static::text('common.visibility'))
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->description(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||
? static::text('resource.visibility_source_unavailable_description')
|
||||
: null),
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->label('Category')
|
||||
->label(static::text('common.category'))
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('restore_mode')
|
||||
->label('Restore')
|
||||
->label(static::text('common.restore'))
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||
@ -374,19 +404,22 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('settings_status')
|
||||
->label('Settings')
|
||||
->label(static::text('common.settings'))
|
||||
->badge()
|
||||
->state(function (Policy $record) {
|
||||
$latest = $record->versions->first();
|
||||
$snapshot = $latest?->snapshot ?? [];
|
||||
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
||||
|
||||
return $hasSettings ? 'Available' : 'Missing';
|
||||
return $hasSettings
|
||||
? static::text('resource.settings_available')
|
||||
: static::text('resource.settings_missing');
|
||||
})
|
||||
->color(function (Policy $record) {
|
||||
$latest = $record->versions->first();
|
||||
@ -396,12 +429,12 @@ public static function table(Table $table): Table
|
||||
return $hasSettings ? 'success' : 'gray';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('external_id')
|
||||
->label('External ID')
|
||||
->label(static::text('common.external_id'))
|
||||
->copyable()
|
||||
->limit(32)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('last_synced_at')
|
||||
->label('Last synced')
|
||||
->label(static::text('common.last_synced'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
@ -411,27 +444,35 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('visibility')
|
||||
->label('Visibility')
|
||||
->label(static::text('common.visibility'))
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'ignored' => 'Ignored',
|
||||
'active' => static::text('resource.filter_active'),
|
||||
'ignored' => static::text('resource.filter_ignored'),
|
||||
'provider_missing' => static::text('resource.filter_source_unavailable'),
|
||||
'all' => static::text('resource.filter_all'),
|
||||
])
|
||||
->default('active')
|
||||
->query(function (Builder $query, array $data) {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (blank($value)) {
|
||||
if (blank($value) || $value === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'active') {
|
||||
$query->whereNull('ignored_at');
|
||||
$query->active();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'ignored') {
|
||||
$query->whereNotNull('ignored_at');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'provider_missing') {
|
||||
$query->whereNotNull('missing_from_provider_at');
|
||||
}
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('policy_type')
|
||||
@ -475,20 +516,22 @@ public static function table(Table $table): Table
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export')
|
||||
->label('Export to Backup')
|
||||
->label(static::text('resource.export_to_backup'))
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->disabled(fn (Policy $record): bool => ! $record->isCurrentBackupEligible())
|
||||
->tooltip(fn (Policy $record): ?string => $record->currentBackupBlockedReasonLabel())
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->label(static::text('common.backup_name'))
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Policy $record, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -496,6 +539,16 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $record->isCurrentBackupEligible()) {
|
||||
Notification::make()
|
||||
->title(static::text('resource.current_backup_unavailable'))
|
||||
->body($record->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = [(int) $record->getKey()];
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -510,7 +563,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
|
||||
@ -533,7 +586,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -541,11 +594,12 @@ public static function table(Table $table): Table
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveDisabled()
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync')
|
||||
->label(static::text('resource.sync_action_secondary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
@ -554,7 +608,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -579,7 +633,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -592,7 +646,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -604,7 +658,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->label(static::text('resource.restore_action'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -613,19 +667,19 @@ public static function table(Table $table): Table
|
||||
$record->unignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy restored')
|
||||
->title(static::text('resource.policy_restored'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
->tooltip(static::text('resource.restore_permission_tooltip'))
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('ignore')
|
||||
->label('Ignore')
|
||||
->label(static::text('resource.ignore_action'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -634,31 +688,31 @@ public static function table(Table $table): Table
|
||||
$record->ignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy ignored')
|
||||
->title(static::text('resource.policy_ignored'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
->tooltip(static::text('resource.ignore_permission_tooltip'))
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->label(static::text('common.more'))
|
||||
->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_export')
|
||||
->label('Export to Backup')
|
||||
->label(static::text('resource.export_to_backup'))
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->label(static::text('common.backup_name'))
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -666,7 +720,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -674,6 +728,20 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$blocked = $records->first(
|
||||
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
|
||||
);
|
||||
|
||||
if ($blocked instanceof Policy) {
|
||||
Notification::make()
|
||||
->title(static::text('resource.current_backup_unavailable'))
|
||||
->body($blocked->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
@ -686,7 +754,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
||||
@ -721,7 +789,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -732,7 +800,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_sync')
|
||||
->label('Sync Policies')
|
||||
->label(static::text('resource.sync_action_primary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
@ -746,7 +814,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -779,7 +847,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -792,7 +860,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -803,7 +871,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Policies')
|
||||
->label(static::text('resource.restore_bulk_action'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -819,7 +887,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -839,7 +907,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'policy.unignore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
||||
@ -873,7 +941,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -884,7 +952,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Ignore Policies')
|
||||
->label(static::text('resource.ignore_bulk_action'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -898,11 +966,11 @@ public static function table(Table $table): Table
|
||||
if ($records->count() >= 20) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@ -915,7 +983,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -931,7 +999,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'policy.delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
|
||||
@ -955,10 +1023,10 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -967,10 +1035,10 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -979,10 +1047,10 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])->label(static::text('common.more')),
|
||||
])
|
||||
->emptyStateHeading('No policies synced yet')
|
||||
->emptyStateDescription('Sync your first tenant to see Intune policies here.')
|
||||
->emptyStateHeading(static::text('resource.empty_state_heading'))
|
||||
->emptyStateDescription(static::text('resource.empty_state_description'))
|
||||
->emptyStateIcon('heroicon-o-arrow-path')
|
||||
->emptyStateActions([
|
||||
static::makeSyncAction(),
|
||||
@ -1159,25 +1227,25 @@ private static function generalOverviewState(Policy $record): array
|
||||
|
||||
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
||||
if (is_string($name) && $name !== '') {
|
||||
$entries[] = ['key' => 'Name', 'value' => $name];
|
||||
$entries[] = ['key' => static::text('resource.general_field_name'), 'value' => $name];
|
||||
}
|
||||
|
||||
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
||||
if (is_string($platforms) && $platforms !== '') {
|
||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
||||
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||
} elseif (is_array($platforms) && $platforms !== []) {
|
||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
||||
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||
}
|
||||
|
||||
$technologies = $snapshot['technologies'] ?? null;
|
||||
if (is_string($technologies) && $technologies !== '') {
|
||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
||||
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||
} elseif (is_array($technologies) && $technologies !== []) {
|
||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
||||
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||
}
|
||||
|
||||
if (array_key_exists('templateReference', $snapshot)) {
|
||||
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
|
||||
$entries[] = ['key' => static::text('resource.general_field_template_reference'), 'value' => $snapshot['templateReference']];
|
||||
}
|
||||
|
||||
$settingCount = $snapshot['settingCount']
|
||||
@ -1185,29 +1253,29 @@ private static function generalOverviewState(Policy $record): array
|
||||
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
||||
|
||||
if (is_int($settingCount) || is_numeric($settingCount)) {
|
||||
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
|
||||
$entries[] = ['key' => static::text('resource.general_field_setting_count'), 'value' => $settingCount];
|
||||
}
|
||||
|
||||
$version = $snapshot['version'] ?? null;
|
||||
if (is_string($version) && $version !== '') {
|
||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
||||
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||
} elseif (is_numeric($version)) {
|
||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
||||
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||
}
|
||||
|
||||
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
||||
if (is_string($lastModified) && $lastModified !== '') {
|
||||
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
|
||||
$entries[] = ['key' => static::text('resource.general_field_last_modified'), 'value' => $lastModified];
|
||||
}
|
||||
|
||||
$createdAt = $snapshot['createdDateTime'] ?? null;
|
||||
if (is_string($createdAt) && $createdAt !== '') {
|
||||
$entries[] = ['key' => 'Created', 'value' => $createdAt];
|
||||
$entries[] = ['key' => static::text('resource.general_field_created'), 'value' => $createdAt];
|
||||
}
|
||||
|
||||
$description = $snapshot['description'] ?? null;
|
||||
if (is_string($description) && $description !== '') {
|
||||
$entries[] = ['key' => 'Description', 'value' => $description];
|
||||
$entries[] = ['key' => static::text('resource.general_field_description'), 'value' => $description];
|
||||
}
|
||||
|
||||
return [
|
||||
@ -1232,4 +1300,9 @@ private static function settingsTabState(Policy $record): array
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private static function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Jobs\CapturePolicySnapshotJob;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
@ -39,23 +40,37 @@ private function makeCaptureSnapshotAction(): Action
|
||||
{
|
||||
$action = UiEnforcement::forAction(
|
||||
Action::make('capture_snapshot')
|
||||
->label('Capture snapshot')
|
||||
->label($this->text('resource.capture_snapshot_action'))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Capture snapshot now')
|
||||
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
|
||||
->modalHeading($this->text('resource.capture_snapshot_modal_heading'))
|
||||
->modalSubheading($this->text('resource.capture_snapshot_modal_subheading').' '.$this->text('common.source_microsoft_intune'))
|
||||
->disabled(fn (): bool => $this->record instanceof Policy && $this->record->isProviderMissing())
|
||||
->tooltip(fn (): ?string => $this->record instanceof Policy && $this->record->isProviderMissing()
|
||||
? $this->record->currentBackupBlockedReasonLabel()
|
||||
: null)
|
||||
->form([
|
||||
Forms\Components\Checkbox::make('include_assignments')
|
||||
->label('Include assignments')
|
||||
->label($this->text('resource.capture_snapshot_include_assignments'))
|
||||
->default(true)
|
||||
->helperText('Captures assignment include/exclude targeting and filters.'),
|
||||
->helperText($this->text('resource.capture_snapshot_include_assignments_helper')),
|
||||
Forms\Components\Checkbox::make('include_scope_tags')
|
||||
->label('Include scope tags')
|
||||
->label($this->text('resource.capture_snapshot_include_scope_tags'))
|
||||
->default(true)
|
||||
->helperText('Captures policy scope tag IDs.'),
|
||||
->helperText($this->text('resource.capture_snapshot_include_scope_tags_helper')),
|
||||
])
|
||||
->action(function (array $data, AuditLogger $auditLogger) {
|
||||
$policy = $this->record;
|
||||
|
||||
if ($policy instanceof Policy && $policy->isProviderMissing()) {
|
||||
Notification::make()
|
||||
->title($this->text('resource.capture_snapshot_unavailable_title'))
|
||||
->body($policy->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $policy->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
@ -82,7 +97,7 @@ private function makeCaptureSnapshotAction(): Action
|
||||
tenant: $tenant,
|
||||
type: 'policy.capture_snapshot',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $includeAssignments, $includeScopeTags): void {
|
||||
@ -108,11 +123,11 @@ private function makeCaptureSnapshotAction(): Action
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->title('Snapshot already in progress')
|
||||
->body('An active run already exists for this policy. Opening run details.')
|
||||
->title($this->text('resource.capture_snapshot_in_progress_title'))
|
||||
->body($this->text('resource.capture_snapshot_in_progress_body'))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label($this->text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
@ -145,7 +160,7 @@ private function makeCaptureSnapshotAction(): Action
|
||||
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label($this->text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -155,7 +170,8 @@ private function makeCaptureSnapshotAction(): Action
|
||||
->color('primary')
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to capture policy snapshots.')
|
||||
->tooltip($this->text('resource.capture_snapshot_permission_tooltip'))
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
|
||||
if (! $action instanceof Action) {
|
||||
@ -164,4 +180,9 @@ private function makeCaptureSnapshotAction(): Action
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\RestoreService;
|
||||
@ -59,15 +59,15 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||
->label('Restore to Intune')
|
||||
->label($this->text('relation.restore_to_microsoft_intune'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||
->modalHeading(fn (PolicyVersion $record): string => $this->text('relation.restore_heading', ['version' => $record->version_number]))
|
||||
->modalSubheading($this->text('relation.restore_subheading'))
|
||||
->form([
|
||||
Forms\Components\Toggle::make('is_dry_run')
|
||||
->label('Preview only (dry-run)')
|
||||
->label($this->text('common.preview_only_dry_run'))
|
||||
->default(true),
|
||||
])
|
||||
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
||||
@ -75,18 +75,18 @@ public function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Missing tenant or user context.')
|
||||
->title($this->text('relation.missing_context_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant_id !== $tenant->id) {
|
||||
if ($record->managed_environment_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Policy version belongs to a different tenant')
|
||||
->title($this->text('versions.different_tenant_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -103,7 +103,7 @@ public function table(Table $table): Table
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title('Restore run failed to start')
|
||||
->title($this->text('relation.restore_run_failed_title'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
@ -112,7 +112,7 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Restore run started')
|
||||
->title($this->text('relation.restore_run_started_title'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
@ -132,7 +132,7 @@ public function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -146,13 +146,13 @@ public function table(Table $table): Table
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
return $this->text('versions.metadata_only_tooltip');
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -171,10 +171,11 @@ public function table(Table $table): Table
|
||||
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('version_number')->label($this->text('common.version'))->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->label($this->text('common.captured'))->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label($this->text('common.actor')),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label($this->text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
@ -189,8 +190,8 @@ public function table(Table $table): Table
|
||||
$restoreToIntune,
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No versions captured')
|
||||
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
||||
->emptyStateHeading($this->text('relation.no_versions_captured'))
|
||||
->emptyStateDescription($this->text('relation.no_versions_captured_description'));
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
||||
@ -204,7 +205,7 @@ private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record):
|
||||
}
|
||||
|
||||
$resolvedRecord = $policy->versions()
|
||||
->where('tenant_id', (int) $policy->tenant_id)
|
||||
->where('managed_environment_id', (int) $policy->managed_environment_id)
|
||||
->whereKey($recordId)
|
||||
->first();
|
||||
|
||||
@ -214,4 +215,9 @@ private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record):
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
use App\Filament\Support\NormalizedDiffSurface;
|
||||
use App\Filament\Support\NormalizedSettingsSurface;
|
||||
@ -14,7 +15,7 @@
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -69,6 +70,7 @@ class PolicyVersionResource extends Resource
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = PolicyVersion::class;
|
||||
|
||||
@ -82,10 +84,6 @@ class PolicyVersionResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
@ -94,7 +92,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -121,23 +119,25 @@ public static function infolist(Schema $schema): Schema
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
||||
Infolists\Components\TextEntry::make('version_number')->label(static::text('common.version')),
|
||||
Infolists\Components\TextEntry::make('policy_type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Infolists\Components\TextEntry::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
||||
Section::make('Backup quality')
|
||||
Infolists\Components\TextEntry::make('created_by')->label(static::text('common.actor')),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime()->label(static::text('common.captured')),
|
||||
Section::make(static::text('versions.backup_quality_section'))
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('quality_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
@ -145,27 +145,27 @@ public static function infolist(Schema $schema): Schema
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Infolists\Components\TextEntry::make('quality_summary')
|
||||
->label('Backup quality')
|
||||
->label(static::text('versions.backup_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
|
||||
Infolists\Components\TextEntry::make('quality_assignment_signal')
|
||||
->label('Assignment quality')
|
||||
->label(static::text('versions.assignment_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
|
||||
Infolists\Components\TextEntry::make('quality_next_action')
|
||||
->label('Next action')
|
||||
->label(static::text('versions.next_action'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
|
||||
Infolists\Components\TextEntry::make('quality_integrity_warning')
|
||||
->label('Integrity note')
|
||||
->label(static::text('versions.integrity_note'))
|
||||
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
|
||||
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('quality_boundary')
|
||||
->label('Boundary')
|
||||
->label(static::text('versions.boundary'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
Section::make(static::text('versions.related_context_section'))
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
->label('')
|
||||
@ -179,7 +179,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->persistTabInQueryString('tab')
|
||||
->columnSpanFull()
|
||||
->tabs([
|
||||
Tab::make('Normalized settings')
|
||||
Tab::make(static::text('common.settings'))
|
||||
->id('normalized-settings')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
||||
@ -198,14 +198,14 @@ public static function infolist(Schema $schema): Schema
|
||||
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
||||
}),
|
||||
]),
|
||||
Tab::make('Raw JSON')
|
||||
Tab::make(static::text('resource.tab_json'))
|
||||
->id('raw-json')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('snapshot_pretty')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
|
||||
]),
|
||||
Tab::make('Diff')
|
||||
Tab::make(static::text('versions.diff_tab'))
|
||||
->id('diff')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_diff')
|
||||
@ -226,7 +226,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return NormalizedDiffSurface::build($result, 'policy_version');
|
||||
}),
|
||||
Infolists\Components\ViewEntry::make('diff_json')
|
||||
->label('Raw diff (advanced)')
|
||||
->label(static::text('versions.raw_diff_advanced'))
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$previous = $record->previous();
|
||||
@ -275,11 +275,11 @@ public static function infolist(Schema $schema): Schema
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
||||
->label('Prune Versions')
|
||||
->label(static::text('versions.prune_versions'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||
->modalDescription(static::text('versions.prune_modal_description'))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -291,8 +291,8 @@ public static function table(Table $table): Table
|
||||
->form(function (Collection $records) {
|
||||
$fields = [
|
||||
Forms\Components\TextInput::make('retention_days')
|
||||
->label('Retention Days')
|
||||
->helperText('Versions captured within the last N days will be skipped.')
|
||||
->label(static::text('versions.retention_days'))
|
||||
->helperText(static::text('versions.retention_days_helper'))
|
||||
->numeric()
|
||||
->required()
|
||||
->default(90)
|
||||
@ -301,11 +301,11 @@ public static function table(Table $table): Table
|
||||
|
||||
if ($records->count() >= 20) {
|
||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -319,7 +319,7 @@ public static function table(Table $table): Table
|
||||
|
||||
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -340,7 +340,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.prune',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
|
||||
@ -363,7 +363,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -372,11 +372,11 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkPruneVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
||||
->label('Restore Versions')
|
||||
->label(static::text('versions.restore_versions'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -388,15 +388,15 @@ public static function table(Table $table): Table
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->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.')
|
||||
->modalHeading(fn (Collection $records) => static::text('versions.restore_versions_modal_heading', ['count' => $records->count()]))
|
||||
->modalDescription(static::text('versions.restore_versions_modal_description'))
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -417,7 +417,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
@ -438,7 +438,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -447,11 +447,11 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
||||
->label('Force Delete Versions')
|
||||
->label(static::text('versions.force_delete_versions'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -463,15 +463,15 @@ public static function table(Table $table): Table
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||
->modalHeading(fn (Collection $records) => static::text('versions.force_delete_versions_modal_heading', ['count' => $records->count()]))
|
||||
->modalDescription(static::text('versions.force_delete_versions_modal_description'))
|
||||
->form([
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
@ -480,7 +480,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -501,7 +501,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
@ -522,7 +522,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -531,7 +531,7 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
@ -542,13 +542,15 @@ public static function table(Table $table): Table
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->sortable()
|
||||
->searchable()
|
||||
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('version_number')
|
||||
->label(static::text('common.version'))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
@ -556,30 +558,33 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->label(static::text('versions.backup_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
|
||||
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label(static::text('common.actor'))->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->label(static::text('common.captured'))->dateTime()->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('policy_type')
|
||||
->label('Type')
|
||||
->label(static::text('common.type'))
|
||||
->options(FilterOptionCatalog::policyTypes())
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->options(FilterOptionCatalog::platforms())
|
||||
->searchable(),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
FilterPresets::dateRange('captured_at', static::text('common.captured'), 'captured_at'),
|
||||
FilterPresets::archived(),
|
||||
])
|
||||
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
||||
@ -590,17 +595,17 @@ public static function table(Table $table): Table
|
||||
Actions\ActionGroup::make([
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore_via_wizard')
|
||||
->label('Restore via Wizard')
|
||||
->label(static::text('versions.restore_via_wizard'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->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.')
|
||||
->modalHeading(fn (PolicyVersion $record): string => static::text('versions.restore_via_wizard_modal_heading', ['version' => $record->version_number]))
|
||||
->modalSubheading(static::text('versions.restore_via_wizard_modal_subheading'))
|
||||
->visible(function (): bool {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -617,7 +622,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -634,7 +639,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -646,11 +651,11 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You do not have permission to create restore runs.';
|
||||
return static::text('versions.restore_run_permission_tooltip');
|
||||
}
|
||||
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
return static::text('versions.metadata_only_tooltip');
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -659,7 +664,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -676,17 +681,17 @@ public static function table(Table $table): Table
|
||||
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
Notification::make()
|
||||
->title('Restore disabled for metadata-only snapshot')
|
||||
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
||||
->title(static::text('versions.restore_disabled_metadata_title'))
|
||||
->body(static::text('versions.restore_disabled_metadata_body'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||
if (! $tenant || $record->managed_environment_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Policy version belongs to a different tenant')
|
||||
->title(static::text('versions.different_tenant_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -697,7 +702,7 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $policy) {
|
||||
Notification::make()
|
||||
->title('Policy could not be found for this version')
|
||||
->title(static::text('versions.missing_policy_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -705,12 +710,11 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => sprintf(
|
||||
'Policy Version Restore • %s • v%d',
|
||||
$policy->display_name,
|
||||
$record->version_number
|
||||
),
|
||||
'managed_environment_id' => $tenant->id,
|
||||
'name' => static::text('versions.backup_set_name', [
|
||||
'policy' => $policy->display_name,
|
||||
'version' => $record->version_number,
|
||||
]),
|
||||
'created_by' => $user?->email,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
@ -764,7 +768,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'managed_environment_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_version_id' => $record->id,
|
||||
@ -788,7 +792,7 @@ public static function table(Table $table): Table
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->label(static::text('versions.archive'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
@ -797,7 +801,7 @@ public static function table(Table $table): Table
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -815,7 +819,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version archived')
|
||||
->title(static::text('versions.archived_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -823,14 +827,14 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->label(static::text('versions.force_delete'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
@ -839,7 +843,7 @@ public static function table(Table $table): Table
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -857,7 +861,7 @@ public static function table(Table $table): Table
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version permanently deleted')
|
||||
->title(static::text('versions.force_deleted_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -865,7 +869,7 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
@ -873,7 +877,7 @@ public static function table(Table $table): Table
|
||||
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->label(static::text('common.restore'))
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
@ -882,7 +886,7 @@ public static function table(Table $table): Table
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -900,7 +904,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version restored')
|
||||
->title(static::text('versions.restored_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -908,13 +912,13 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
])
|
||||
->label('More')
|
||||
->label(static::text('common.more'))
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
])
|
||||
@ -923,14 +927,14 @@ public static function table(Table $table): Table
|
||||
$bulkPruneVersions,
|
||||
$bulkRestoreVersions,
|
||||
$bulkForceDeleteVersions,
|
||||
])->label('More'),
|
||||
])->label(static::text('common.more')),
|
||||
])
|
||||
->emptyStateHeading('No policy versions')
|
||||
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
|
||||
->emptyStateHeading(static::text('versions.empty_state_heading'))
|
||||
->emptyStateDescription(static::text('versions.empty_state_description'))
|
||||
->emptyStateIcon('heroicon-o-clock')
|
||||
->emptyStateActions([
|
||||
Actions\Action::make('open_backup_sets')
|
||||
->label('Open backup sets')
|
||||
->label(static::text('versions.open_backup_sets'))
|
||||
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->color('gray'),
|
||||
]);
|
||||
@ -1016,7 +1020,7 @@ public static function relatedContextEntries(PolicyVersion $record): array
|
||||
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')
|
||||
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? static::text('versions.related_record_fallback'))
|
||||
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
@ -1032,10 +1036,10 @@ private static function policyVersionAssignmentQualityLabel(PolicyVersion $recor
|
||||
$summary = static::policyVersionQualitySummary($record);
|
||||
|
||||
return match (true) {
|
||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
|
||||
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
|
||||
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
|
||||
default => 'No assignment issues were detected from captured metadata.',
|
||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => static::text('versions.assignment_fetch_failed_orphaned'),
|
||||
$summary->hasAssignmentIssues => static::text('versions.assignment_fetch_failed'),
|
||||
$summary->hasOrphanedAssignments => static::text('versions.assignment_orphaned'),
|
||||
default => static::text('versions.assignment_no_issues'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1065,6 +1069,11 @@ private static function resolvedDisplayName(PolicyVersion $record): string
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
return sprintf('Version %d', (int) $record->version_number);
|
||||
return static::text('versions.fallback_display_name', ['version' => (int) $record->version_number]);
|
||||
}
|
||||
|
||||
private static function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -99,7 +99,7 @@ public static function canCreate(): bool
|
||||
$tenant = static::resolveTenantForCreate();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ protected static function hasTenantCapability(string $capability): bool
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -126,7 +126,7 @@ protected static function hasTenantCapability(string $capability): bool
|
||||
&& $resolver->can($user, $tenant, $capability);
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(): ?Tenant
|
||||
protected static function resolveScopedTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
||||
|
||||
@ -136,19 +136,19 @@ protected static function resolveScopedTenant(): ?Tenant
|
||||
|
||||
$routeTenant = request()->route('tenant');
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
if ($routeTenant instanceof ManagedEnvironment) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $routeTenant)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $routeTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
$recordTenant = static::resolveTenantFromRouteRecord();
|
||||
|
||||
if ($recordTenant instanceof Tenant) {
|
||||
if ($recordTenant instanceof ManagedEnvironment) {
|
||||
return $recordTenant;
|
||||
}
|
||||
|
||||
@ -161,16 +161,16 @@ protected static function resolveScopedTenant(): ?Tenant
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
|
||||
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?ManagedEnvironment
|
||||
{
|
||||
if ($record instanceof ProviderConnection) {
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant && is_numeric($record->tenant_id)) {
|
||||
$tenant = Tenant::query()->whereKey((int) $record->tenant_id)->first();
|
||||
if (! $tenant instanceof ManagedEnvironment && is_numeric($record->managed_environment_id)) {
|
||||
$tenant = ManagedEnvironment::query()->whereKey((int) $record->managed_environment_id)->first();
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
@ -180,7 +180,7 @@ public static function resolveTenantForRecord(?ProviderConnection $record = null
|
||||
|
||||
public static function resolveRequestedTenantExternalId(): ?string
|
||||
{
|
||||
$queryTenant = request()->query('tenant_id');
|
||||
$queryTenant = request()->query('managed_environment_id');
|
||||
|
||||
if (is_string($queryTenant) && $queryTenant !== '') {
|
||||
return $queryTenant;
|
||||
@ -195,26 +195,26 @@ public static function resolveContextTenantExternalId(): ?string
|
||||
$contextTenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
if ($workspaceId !== null && $contextTenantId !== null) {
|
||||
$tenant = Tenant::query()
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->whereKey($contextTenantId)
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return (string) $tenant->external_id;
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return (string) $tenant->external_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function resolveTenantForCreate(): ?Tenant
|
||||
public static function resolveTenantForCreate(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantExternalId = static::resolveRequestedTenantExternalId() ?? static::resolveContextTenantExternalId();
|
||||
|
||||
@ -226,7 +226,7 @@ public static function resolveTenantForCreate(): ?Tenant
|
||||
$user = auth()->user();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User || $workspaceId === null) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || $workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -277,7 +277,7 @@ private static function extractTenantExternalIdFromUrl(string $url): ?string
|
||||
if (is_string($query) && $query !== '') {
|
||||
parse_str($query, $queryParams);
|
||||
|
||||
$tenantExternalId = $queryParams['tenant_id'] ?? null;
|
||||
$tenantExternalId = $queryParams['managed_environment_id'] ?? null;
|
||||
|
||||
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
|
||||
return $tenantExternalId;
|
||||
@ -297,18 +297,18 @@ private static function extractTenantExternalIdFromUrl(string $url): ?string
|
||||
return (string) $matches[1];
|
||||
}
|
||||
|
||||
private static function resolveTenantByExternalId(?string $externalId): ?Tenant
|
||||
private static function resolveTenantByExternalId(?string $externalId): ?ManagedEnvironment
|
||||
{
|
||||
if (! is_string($externalId) || $externalId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->where('external_id', $externalId)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $externalId)
|
||||
->first();
|
||||
}
|
||||
|
||||
private static function resolveTenantFromRouteRecord(): ?Tenant
|
||||
private static function resolveTenantFromRouteRecord(): ?ManagedEnvironment
|
||||
{
|
||||
$record = request()->route('record');
|
||||
|
||||
@ -340,7 +340,7 @@ private static function applyMembershipScope(Builder $query): Builder
|
||||
if (! is_int($workspaceId)) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
}
|
||||
}
|
||||
@ -354,12 +354,12 @@ private static function applyMembershipScope(Builder $query): Builder
|
||||
->whereExists(function ($membershipScope) use ($user, $workspaceId): void {
|
||||
$membershipScope
|
||||
->selectRaw('1')
|
||||
->from('tenants as scoped_tenants')
|
||||
->join('tenant_memberships as scoped_memberships', function (JoinClause $join) use ($user): void {
|
||||
$join->on('scoped_memberships.tenant_id', '=', 'scoped_tenants.id')
|
||||
->from('managed_environments as scoped_tenants')
|
||||
->join('managed_environment_memberships as scoped_memberships', function (JoinClause $join) use ($user): void {
|
||||
$join->on('scoped_memberships.managed_environment_id', '=', 'scoped_tenants.id')
|
||||
->where('scoped_memberships.user_id', '=', (int) $user->getKey());
|
||||
})
|
||||
->whereColumn('scoped_tenants.id', 'provider_connections.tenant_id')
|
||||
->whereColumn('scoped_tenants.id', 'provider_connections.managed_environment_id')
|
||||
->where('scoped_tenants.workspace_id', '=', $workspaceId);
|
||||
});
|
||||
}
|
||||
@ -376,16 +376,16 @@ private static function tenantFilterOptions(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->select(['tenants.external_id', 'tenants.name', 'tenants.environment'])
|
||||
->join('tenant_memberships as filter_memberships', function (JoinClause $join) use ($user): void {
|
||||
$join->on('filter_memberships.tenant_id', '=', 'tenants.id')
|
||||
return ManagedEnvironment::query()
|
||||
->select(['managed_environments.slug', 'managed_environments.name', 'managed_environments.kind'])
|
||||
->join('managed_environment_memberships as filter_memberships', function (JoinClause $join) use ($user): void {
|
||||
$join->on('filter_memberships.managed_environment_id', '=', 'managed_environments.id')
|
||||
->where('filter_memberships.user_id', '=', (int) $user->getKey());
|
||||
})
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->where('managed_environments.workspace_id', $workspaceId)
|
||||
->orderBy('managed_environments.name')
|
||||
->get()
|
||||
->mapWithKeys(function (Tenant $tenant): array {
|
||||
->mapWithKeys(function (ManagedEnvironment $tenant): array {
|
||||
$environment = strtoupper((string) ($tenant->environment ?? ''));
|
||||
$label = $environment !== '' ? "{$tenant->name} ({$environment})" : (string) $tenant->name;
|
||||
|
||||
@ -506,7 +506,7 @@ private static function targetScopeSummary(?ProviderConnection $record): string
|
||||
}
|
||||
}
|
||||
|
||||
private static function providerIdentityContext(?ProviderConnection $record): ?string
|
||||
private static function providerContextSummary(?ProviderConnection $record): ?string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return null;
|
||||
@ -519,6 +519,68 @@ private static function providerIdentityContext(?ProviderConnection $record): ?s
|
||||
}
|
||||
}
|
||||
|
||||
private static function providerCapabilitySummary(?ProviderConnection $record): string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return 'Provider capabilities are evaluated after this connection is saved.';
|
||||
}
|
||||
|
||||
try {
|
||||
return ProviderConnectionSurfaceSummary::forConnection($record)->providerCapabilitySummary();
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Provider capability needs review';
|
||||
}
|
||||
}
|
||||
|
||||
private static function providerCapabilityStatus(?ProviderConnection $record): string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
$summary = ProviderConnectionSurfaceSummary::forConnection($record)->toArray();
|
||||
$primary = is_array($summary['primary_provider_capability'] ?? null)
|
||||
? $summary['primary_provider_capability']
|
||||
: [];
|
||||
|
||||
return (string) ($primary['status'] ?? 'unknown');
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private static function providerCapabilitiesLine(?ProviderConnection $record): string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return 'Provider capabilities are evaluated after this connection is saved.';
|
||||
}
|
||||
|
||||
try {
|
||||
$summary = ProviderConnectionSurfaceSummary::forConnection($record)->toArray();
|
||||
$capabilities = is_array($summary['provider_capabilities'] ?? null)
|
||||
? $summary['provider_capabilities']
|
||||
: [];
|
||||
} catch (InvalidArgumentException) {
|
||||
$capabilities = [];
|
||||
}
|
||||
|
||||
if ($capabilities === []) {
|
||||
return 'Provider capabilities not evaluated.';
|
||||
}
|
||||
|
||||
return collect($capabilities)
|
||||
->filter(fn (mixed $capability): bool => is_array($capability))
|
||||
->map(function (array $capability): string {
|
||||
$label = (string) ($capability['label'] ?? 'Provider capability');
|
||||
$status = (string) ($capability['status'] ?? 'unknown');
|
||||
$statusLabel = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $status)->label;
|
||||
|
||||
return "{$label}: {$statusLabel}";
|
||||
})
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, mixed>
|
||||
@ -539,7 +601,10 @@ public static function targetScopeAuditMetadata(ProviderConnection $record, arra
|
||||
'shared_label' => 'Target scope',
|
||||
'shared_help_text' => static::targetScopeHelpText(),
|
||||
],
|
||||
'provider_identity_context' => [],
|
||||
'provider_context' => [
|
||||
'provider' => (string) $record->provider,
|
||||
'details' => [],
|
||||
],
|
||||
], $extra);
|
||||
}
|
||||
}
|
||||
@ -594,6 +659,9 @@ public static function form(Schema $schema): Schema
|
||||
Placeholder::make('verification_status_display')
|
||||
->label('Verification')
|
||||
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
||||
Placeholder::make('provider_capability_display')
|
||||
->label('Provider capability')
|
||||
->content(fn (?ProviderConnection $record): string => static::providerCapabilitySummary($record)),
|
||||
Placeholder::make('last_health_check_at_display')
|
||||
->label('Last check')
|
||||
->content(fn (?ProviderConnection $record): string => $record?->last_health_check_at?->diffForHumans() ?? 'Never'),
|
||||
@ -670,6 +738,24 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
||||
Infolists\Components\TextEntry::make('provider_capability')
|
||||
->label('Provider capability')
|
||||
->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderCapabilityStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderCapabilityStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderCapabilityStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderCapabilityStatus))
|
||||
->helperText(fn (ProviderConnection $record): string => static::providerCapabilitySummary($record)),
|
||||
Infolists\Components\TextEntry::make('provider_capability_summary')
|
||||
->label('Capability summary')
|
||||
->state(fn (ProviderConnection $record): string => static::providerCapabilitySummary($record))
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('provider_capabilities')
|
||||
->label('Capability detail')
|
||||
->state(fn (ProviderConnection $record): string => static::providerCapabilitiesLine($record))
|
||||
->listWithLineBreaks()
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('last_health_check_at')
|
||||
->label('Last check')
|
||||
->since(),
|
||||
@ -681,9 +767,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Migration review')
|
||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
Infolists\Components\TextEntry::make('provider_identity_context')
|
||||
->label('Provider identity details')
|
||||
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
|
||||
Infolists\Components\TextEntry::make('provider_context')
|
||||
->label('Provider context')
|
||||
->state(fn (ProviderConnection $record): ?string => static::providerContextSummary($record))
|
||||
->placeholder('n/a')
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('last_error_reason_code')
|
||||
@ -712,7 +798,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
return $query->whereHas('tenant', function (Builder $tenantQuery) use ($tenantExternalId): void {
|
||||
$tenantQuery->where('external_id', $tenantExternalId);
|
||||
$tenantQuery->where('slug', $tenantExternalId);
|
||||
});
|
||||
})
|
||||
->defaultSort('display_name')
|
||||
@ -723,7 +809,7 @@ public static function table(Table $table): Table
|
||||
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->description(function (ProviderConnection $record): ?string {
|
||||
$environment = $record->tenant?->environment;
|
||||
|
||||
@ -736,7 +822,7 @@ public static function table(Table $table): Table
|
||||
->url(function (ProviderConnection $record): ?string {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -780,6 +866,16 @@ public static function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
||||
Tables\Columns\TextColumn::make('provider_capability')
|
||||
->label('Provider capability')
|
||||
->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderCapabilityStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderCapabilityStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderCapabilityStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderCapabilityStatus))
|
||||
->description(fn (ProviderConnection $record): string => static::providerCapabilitySummary($record))
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->badge()
|
||||
@ -801,7 +897,7 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->default(static::resolveScopedTenant()?->external_id)
|
||||
->options(static::tenantFilterOptions())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
@ -812,7 +908,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
return $query->whereHas('tenant', function (Builder $tenantQuery) use ($value): void {
|
||||
$tenantQuery->where('external_id', $value);
|
||||
$tenantQuery->where('slug', $value);
|
||||
});
|
||||
}),
|
||||
SelectFilter::make('provider')
|
||||
@ -952,7 +1048,7 @@ public static function makeInventorySyncAction(): Actions\Action
|
||||
gate: $gate,
|
||||
operationType: OperationRunType::InventorySync->value,
|
||||
blockedTitle: 'Inventory sync blocked',
|
||||
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
dispatcher: function (ManagedEnvironment $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
ProviderInventorySyncJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
@ -983,7 +1079,7 @@ public static function makeComplianceSnapshotAction(): Actions\Action
|
||||
gate: $gate,
|
||||
operationType: 'compliance.snapshot',
|
||||
blockedTitle: 'Compliance snapshot blocked',
|
||||
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
dispatcher: function (ManagedEnvironment $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
ProviderComplianceSnapshotJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
@ -1012,7 +1108,7 @@ public static function makeSetDefaultAction(): Actions\Action
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1070,7 +1166,7 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger) use ($source): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1147,7 +1243,7 @@ public static function makeRotateDedicatedCredentialAction(?string $modalDescrip
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1185,7 +1281,7 @@ public static function makeDeleteDedicatedCredentialAction(?string $modalDescrip
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1218,7 +1314,7 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger) use ($source): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1275,7 +1371,7 @@ public static function makeEnableConnectionAction(): Actions\Action
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1350,7 +1446,7 @@ public static function makeDisableConnectionAction(): Actions\Action
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1398,7 +1494,7 @@ private static function recordAllowsProviderExecution(ProviderConnection $record
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
return $tenant instanceof ManagedEnvironment
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& (bool) $record->is_enabled;
|
||||
@ -1409,7 +1505,7 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -1464,7 +1560,7 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(Tenant, User, ProviderConnection, OperationRun): void $dispatcher
|
||||
* @param callable(ManagedEnvironment, User, ProviderConnection, OperationRun): void $dispatcher
|
||||
*/
|
||||
private static function handleProviderOperationAction(
|
||||
ProviderConnection $record,
|
||||
@ -1477,7 +1573,7 @@ private static function handleProviderOperationAction(
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1543,7 +1639,7 @@ public static function getPages(): array
|
||||
|
||||
private static function normalizeTenantExternalId(mixed $tenant): ?string
|
||||
{
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return (string) $tenant->external_id;
|
||||
}
|
||||
|
||||
@ -1552,9 +1648,9 @@ private static function normalizeTenantExternalId(mixed $tenant): ?string
|
||||
}
|
||||
|
||||
if (is_numeric($tenant)) {
|
||||
$tenantModel = Tenant::query()->whereKey((int) $tenant)->first();
|
||||
$tenantModel = ManagedEnvironment::query()->whereKey((int) $tenant)->first();
|
||||
|
||||
if ($tenantModel instanceof Tenant) {
|
||||
if ($tenantModel instanceof ManagedEnvironment) {
|
||||
return (string) $tenantModel->external_id;
|
||||
}
|
||||
}
|
||||
@ -1575,7 +1671,7 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
|
||||
unset($parameters['tenant']);
|
||||
}
|
||||
|
||||
if ($tenantExternalId === null && $tenant instanceof Tenant) {
|
||||
if ($tenantExternalId === null && $tenant instanceof ManagedEnvironment) {
|
||||
$tenantExternalId = (string) $tenant->external_id;
|
||||
}
|
||||
|
||||
@ -1591,8 +1687,8 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
|
||||
$tenantExternalId = static::resolveScopedTenant()?->external_id;
|
||||
}
|
||||
|
||||
if (! array_key_exists('tenant_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') {
|
||||
$parameters['tenant_id'] = $tenantExternalId;
|
||||
if (! array_key_exists('managed_environment_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') {
|
||||
$parameters['managed_environment_id'] = $tenantExternalId;
|
||||
}
|
||||
|
||||
return parent::getUrl($name, $parameters, $isAbsolute, $panel, null, $shouldGuessMissingParameters);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
@ -26,7 +26,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||
'display_name' => $data['display_name'],
|
||||
@ -73,7 +73,7 @@ protected function afterCreate(): void
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -115,11 +115,11 @@ protected function afterCreate(): void
|
||||
->send();
|
||||
}
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
private function currentTenant(): ?ManagedEnvironment
|
||||
{
|
||||
$tenant = ProviderConnectionResource::resolveTenantForCreate();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -48,13 +48,13 @@ public function mount($record): void
|
||||
? ProviderConnectionResource::resolveTenantForRecord($this->record)
|
||||
: null;
|
||||
|
||||
if ($recordTenant instanceof Tenant) {
|
||||
if ($recordTenant instanceof ManagedEnvironment) {
|
||||
$this->scopedTenantExternalId = (string) $recordTenant->external_id;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantIdFromQuery = request()->query('tenant_id');
|
||||
$tenantIdFromQuery = request()->query('managed_environment_id');
|
||||
|
||||
if (is_string($tenantIdFromQuery) && $tenantIdFromQuery !== '') {
|
||||
$this->scopedTenantExternalId = $tenantIdFromQuery;
|
||||
@ -64,7 +64,7 @@ public function mount($record): void
|
||||
|
||||
$tenant = request()->route('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$this->scopedTenantExternalId = (string) $tenant->external_id;
|
||||
|
||||
return;
|
||||
@ -107,7 +107,7 @@ protected function afterSave(): void
|
||||
? ($record->tenant ?? $this->currentTenant())
|
||||
: $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -182,21 +182,21 @@ protected function getHeaderActions(): array
|
||||
->label('View last check run')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof ManagedEnvironment
|
||||
&& OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
->where('context->provider_connection_id', (int) $record->getKey())
|
||||
->exists())
|
||||
->url(function (ProviderConnection $record): ?string {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
->where('context->provider_connection_id', (int) $record->getKey())
|
||||
->orderByDesc('id')
|
||||
@ -246,7 +246,7 @@ protected function getFormActions(): array
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return [
|
||||
$this->getCancelFormAction(),
|
||||
];
|
||||
@ -271,7 +271,7 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -288,40 +288,40 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
return parent::handleRecordUpdate($record, $data);
|
||||
}
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
private function currentTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if (isset($this->record) && $this->record instanceof ProviderConnection) {
|
||||
$recordTenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if ($recordTenant instanceof Tenant) {
|
||||
if ($recordTenant instanceof ManagedEnvironment) {
|
||||
return $recordTenant;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $this->scopedTenantExternalId)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $this->scopedTenantExternalId)
|
||||
->first();
|
||||
}
|
||||
|
||||
$tenant = request()->route('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if (is_string($tenant) && $tenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenant)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $tenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
$tenantFromCreateResolution = ProviderConnectionResource::resolveTenantForCreate();
|
||||
|
||||
if ($tenantFromCreateResolution instanceof Tenant) {
|
||||
if ($tenantFromCreateResolution instanceof ManagedEnvironment) {
|
||||
return $tenantFromCreateResolution;
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
return ManagedEnvironment::current();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -48,7 +48,7 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::getUrl('create', [
|
||||
'tenant_id' => $tenantExternalId,
|
||||
'managed_environment_id' => $tenantExternalId,
|
||||
]);
|
||||
})
|
||||
->visible(function () use ($resolver): bool {
|
||||
@ -59,7 +59,7 @@ protected function getHeaderActions(): array
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ protected function getHeaderActions(): array
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -90,7 +90,7 @@ protected function getHeaderActions(): array
|
||||
->tooltip(function () use ($resolver): ?string {
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'Select a tenant to create provider connections.';
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ protected function getHeaderActions(): array
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
return $tenant instanceof ManagedEnvironment
|
||||
&& $user instanceof User
|
||||
&& $resolver->isMember($user, $tenant);
|
||||
}),
|
||||
@ -136,14 +136,14 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::getUrl('create', [
|
||||
'tenant_id' => $tenantExternalId,
|
||||
'managed_environment_id' => $tenantExternalId,
|
||||
]);
|
||||
})
|
||||
->visible(function () use ($resolver): bool {
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -157,7 +157,7 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -174,7 +174,7 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction
|
||||
->tooltip(function () use ($resolver): ?string {
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'Select a tenant to create provider connections.';
|
||||
}
|
||||
|
||||
@ -198,7 +198,7 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction
|
||||
$tenant = $this->resolveTenantForCreateAction();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
return $tenant instanceof ManagedEnvironment
|
||||
&& $user instanceof User
|
||||
&& $resolver->isMember($user, $tenant);
|
||||
});
|
||||
@ -222,7 +222,7 @@ private function resolveTenantExternalIdForCreateAction(): ?string
|
||||
return ProviderConnectionResource::resolveContextTenantExternalId();
|
||||
}
|
||||
|
||||
private function resolveTenantForCreateAction(): ?Tenant
|
||||
private function resolveTenantForCreateAction(): ?ManagedEnvironment
|
||||
{
|
||||
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
|
||||
|
||||
@ -230,8 +230,8 @@ private function resolveTenantForCreateAction(): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenantExternalId)
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $tenantExternalId)
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -25,11 +25,11 @@ protected function getHeaderActions(): array
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(function () use ($tenant): ?string {
|
||||
return $tenant instanceof Tenant
|
||||
return $tenant instanceof ManagedEnvironment
|
||||
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
|
||||
: null;
|
||||
})
|
||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
||||
->visible(fn (): bool => $tenant instanceof ManagedEnvironment)
|
||||
->openUrlInNewTab()
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
@ -64,7 +64,7 @@ private function sharedConnectionActions(): array
|
||||
];
|
||||
}
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
private function currentTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if (! $this->record instanceof ProviderConnection) {
|
||||
return null;
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||
@ -16,7 +17,7 @@
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Rules\SkipOrUuidRule;
|
||||
@ -88,6 +89,7 @@ class RestoreRunResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = RestoreRun::class;
|
||||
|
||||
@ -95,10 +97,6 @@ class RestoreRunResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
@ -111,7 +109,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -127,7 +125,7 @@ public static function canCreate(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -192,7 +190,7 @@ public static function form(Schema $schema): Schema
|
||||
tenant: $tenant
|
||||
);
|
||||
|
||||
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
||||
$groupCacheQuery = EntraGroup::query()->where('managed_environment_id', $tenant->getKey());
|
||||
$hasCachedGroups = $groupCacheQuery->exists();
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
@ -505,7 +503,7 @@ public static function getWizardSteps(): array
|
||||
tenant: $tenant
|
||||
);
|
||||
|
||||
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
||||
$groupCacheQuery = EntraGroup::query()->where('managed_environment_id', $tenant->getKey());
|
||||
$hasCachedGroups = $groupCacheQuery->exists();
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
@ -630,7 +628,7 @@ public static function getWizardSteps(): array
|
||||
|
||||
$backupSet = BackupSet::find($backupSetId);
|
||||
|
||||
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
||||
if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Unable to run checks')
|
||||
->body('Backup set is not available for the active tenant.')
|
||||
@ -749,7 +747,7 @@ public static function getWizardSteps(): array
|
||||
|
||||
$backupSet = BackupSet::find($backupSetId);
|
||||
|
||||
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
||||
if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Unable to generate preview')
|
||||
->body('Backup set is not available for the active tenant.')
|
||||
@ -827,7 +825,7 @@ public static function getWizardSteps(): array
|
||||
->label('Environment')
|
||||
->content(fn (): string => app()->environment('production') ? 'prod' : 'test'),
|
||||
Forms\Components\Placeholder::make('confirm_tenant_label')
|
||||
->label('Tenant hard-confirm label')
|
||||
->label('ManagedEnvironment hard-confirm label')
|
||||
->content(function (): string {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
@ -835,7 +833,7 @@ public static function getWizardSteps(): array
|
||||
return '';
|
||||
}
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
|
||||
|
||||
return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||
}),
|
||||
@ -914,12 +912,12 @@ public static function getWizardSteps(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
|
||||
|
||||
return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())];
|
||||
})
|
||||
->validationMessages([
|
||||
'in' => 'Tenant hard-confirm does not match.',
|
||||
'in' => 'ManagedEnvironment hard-confirm does not match.',
|
||||
])
|
||||
->helperText(function (): string {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -928,7 +926,7 @@ public static function getWizardSteps(): array
|
||||
return '';
|
||||
}
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
|
||||
$expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||
|
||||
return "Type: {$expected}";
|
||||
@ -1156,7 +1154,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1173,7 +1171,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'restore_run.delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
@ -1226,7 +1224,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1243,7 +1241,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'restore_run.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
|
||||
@ -1316,7 +1314,7 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1333,7 +1331,7 @@ public static function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
type: 'restore_run.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
|
||||
@ -1469,13 +1467,17 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
|
||||
$items = BackupItem::query()
|
||||
->where('backup_set_id', $backupSetId)
|
||||
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
||||
->whereHas('backupSet', fn ($query) => $query->where('managed_environment_id', $tenant->getKey()))
|
||||
->where(function ($query) {
|
||||
$query->whereNull('policy_id')
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
->orWhereHas('policy', function ($policyQuery): void {
|
||||
$policyQuery
|
||||
->whereNull('ignored_at')
|
||||
->orWhereNotNull('missing_from_provider_at');
|
||||
});
|
||||
})
|
||||
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
||||
->with(['policy:id,display_name,missing_from_provider_at,ignored_at', 'policyVersion:id,version_number,captured_at'])
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
@ -1499,6 +1501,9 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$displayName = $item->resolvedDisplayName();
|
||||
$identifier = $item->policy_identifier ?? null;
|
||||
$versionNumber = $item->policyVersion?->version_number;
|
||||
$providerMissingNote = $item->policy?->missing_from_provider_at
|
||||
? 'current state: provider missing; historical restore available'
|
||||
: null;
|
||||
|
||||
$options[$item->id] = $displayName;
|
||||
|
||||
@ -1508,6 +1513,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$platform,
|
||||
'quality: '.$qualitySummary->compactSummary,
|
||||
"restore: {$restore}",
|
||||
$providerMissingNote,
|
||||
$versionNumber ? "version: {$versionNumber}" : null,
|
||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||
@ -1536,13 +1542,17 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
|
||||
$items = BackupItem::query()
|
||||
->where('backup_set_id', $backupSetId)
|
||||
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
||||
->whereHas('backupSet', fn ($query) => $query->where('managed_environment_id', $tenant->getKey()))
|
||||
->where(function ($query) {
|
||||
$query->whereNull('policy_id')
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
->orWhereHas('policy', function ($policyQuery): void {
|
||||
$policyQuery
|
||||
->whereNull('ignored_at')
|
||||
->orWhereNotNull('missing_from_provider_at');
|
||||
});
|
||||
})
|
||||
->with(['policy:id,display_name'])
|
||||
->with(['policy:id,display_name,missing_from_provider_at,ignored_at'])
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
@ -1586,7 +1596,7 @@ private static function restoreBackupSetOptions(): array
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->with([
|
||||
'items' => fn ($query) => $query->select([
|
||||
'id',
|
||||
@ -1626,12 +1636,12 @@ private static function restoreBackupSetHelperText(mixed $backupSetId): string
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$backupSet = BackupSet::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->with([
|
||||
'items' => fn ($query) => $query->select([
|
||||
'id',
|
||||
@ -1659,6 +1669,7 @@ private static function restoreItemSelectionLabel(BackupItem $item): string
|
||||
return implode(' • ', array_filter([
|
||||
$item->resolvedDisplayName(),
|
||||
$summary->compactSummary,
|
||||
$item->policy?->missing_from_provider_at ? 'provider missing now' : null,
|
||||
]));
|
||||
}
|
||||
|
||||
@ -1682,7 +1693,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -1700,7 +1711,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
/** @var BackupSet $backupSet */
|
||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
||||
|
||||
if ($backupSet->tenant_id !== $tenant->id) {
|
||||
if ($backupSet->managed_environment_id !== $tenant->id) {
|
||||
abort(403, 'Backup set does not belong to the active tenant.');
|
||||
}
|
||||
|
||||
@ -1743,7 +1754,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$previewDiffs = $data['preview_diffs'] ?? null;
|
||||
$previewRanAt = $data['preview_ran_at'] ?? null;
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
|
||||
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||
|
||||
if (! $isDryRun) {
|
||||
@ -1822,7 +1833,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
|
||||
if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_confirm' => 'Tenant hard-confirm does not match.',
|
||||
'tenant_confirm' => 'ManagedEnvironment hard-confirm does not match.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1975,7 +1986,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
* @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun}
|
||||
*/
|
||||
private static function startQueuedRestoreExecution(
|
||||
Tenant $tenant,
|
||||
ManagedEnvironment $tenant,
|
||||
BackupSet $backupSet,
|
||||
?array $selectedItemIds,
|
||||
array $preview,
|
||||
@ -2016,7 +2027,7 @@ private static function startQueuedRestoreExecution(
|
||||
&$queuedRestoreRun,
|
||||
): void {
|
||||
$queuedRestoreRun = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'managed_environment_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'operation_run_id' => $run->getKey(),
|
||||
'requested_by' => $actorEmail,
|
||||
@ -2124,7 +2135,7 @@ private static function startQueuedRestoreExecution(
|
||||
* @param array<int>|null $selectedItemIds
|
||||
*/
|
||||
private static function guardRestoreExecutionOperationalControl(
|
||||
Tenant $tenant,
|
||||
ManagedEnvironment $tenant,
|
||||
BackupSet $backupSet,
|
||||
?array $selectedItemIds,
|
||||
?User $initiator,
|
||||
@ -2228,7 +2239,7 @@ private static function wizardSafetyState(array $data): array
|
||||
$previewIntegrity = $resolver->previewIntegrityFromData($data);
|
||||
$checksIntegrity = $resolver->checksIntegrityFromData($data);
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return [
|
||||
'currentScope' => $scope,
|
||||
'previewIntegrity' => $previewIntegrity->toArray(),
|
||||
@ -2333,7 +2344,7 @@ private static function restoreSafetyResolver(): RestoreSafetyResolver
|
||||
* @param array<int>|null $selectedItemIds
|
||||
* @return array<int, array{id:string,label:string}>
|
||||
*/
|
||||
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array
|
||||
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, ManagedEnvironment $tenant): array
|
||||
{
|
||||
if (! $backupSetId) {
|
||||
return [];
|
||||
@ -2408,7 +2419,7 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem
|
||||
* @param array<int>|null $selectedItemIds
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?Tenant $tenant): array
|
||||
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?ManagedEnvironment $tenant): array
|
||||
{
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
@ -2588,7 +2599,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
|
||||
$actorEmail = auth()->user()?->email;
|
||||
$actorName = auth()->user()?->name;
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
|
||||
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||
|
||||
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
|
||||
@ -2716,7 +2727,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -2740,8 +2751,8 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return 'Tenant unavailable';
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'ManagedEnvironment unavailable';
|
||||
}
|
||||
|
||||
// Check RBAC capability first
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -28,7 +28,7 @@ protected function authorizeAccess(): void
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -69,7 +69,7 @@ protected function afterFill(): void
|
||||
}
|
||||
|
||||
$belongsToTenant = BackupSet::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->whereKey($backupSetId)
|
||||
->exists();
|
||||
|
||||
|
||||
@ -3,13 +3,14 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -50,6 +51,7 @@
|
||||
class ReviewPackResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static ?string $model = ReviewPack::class;
|
||||
|
||||
@ -67,10 +69,10 @@ class ReviewPackResource extends Resource
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -83,10 +85,10 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -99,7 +101,7 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
if ($record instanceof ReviewPack) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
return (int) $record->managed_environment_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -142,13 +144,14 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('tenant.name')->label('ManagedEnvironment'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('file_size')
|
||||
->label('File size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -184,13 +187,14 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
])
|
||||
->columns(2)
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||
TextEntry::make('tenantReview.id')
|
||||
->label('Tenant review')
|
||||
->label('ManagedEnvironment 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)
|
||||
@ -199,7 +203,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('customer_workspace')
|
||||
->label('Customer workspace')
|
||||
->state(fn (): string => 'Open workspace')
|
||||
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
|
||||
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof ManagedEnvironment
|
||||
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
|
||||
: null)
|
||||
->placeholder('—'),
|
||||
@ -220,16 +224,19 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
|
||||
}
|
||||
|
||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
||||
})
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
@ -243,9 +250,7 @@ public static function infolist(Schema $schema): Schema
|
||||
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),
|
||||
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
|
||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||
->label('Snapshot completeness')
|
||||
->badge()
|
||||
@ -288,7 +293,7 @@ public static function table(Table $table): Table
|
||||
->description(fn (ReviewPack $record): ?string => static::compressedOutcome($record)->primaryReason)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')
|
||||
->dateTime()
|
||||
@ -410,15 +415,15 @@ public static function reviewPackGenerationFormSchema(): array
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
|
||||
->where('tenant_id', (int) $tenant->getKey());
|
||||
->where('managed_environment_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
@ -429,6 +434,36 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
{
|
||||
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
private static function evidenceSnapshotUrl(ReviewPack $record): ?string
|
||||
{
|
||||
if (! $record->evidenceSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant);
|
||||
|
||||
return static::isCustomerWorkspaceFlow()
|
||||
? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
|
||||
: $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
@ -468,10 +503,10 @@ private static function compressedOutcome(ReviewPack $record, bool $fresh = fals
|
||||
*/
|
||||
public static function executeGeneration(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::currentTenantContext();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to generate pack — missing context.')->send();
|
||||
|
||||
return;
|
||||
@ -539,30 +574,30 @@ public static function executeGeneration(array $data): void
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||
public static function reviewPackGenerationDecision(?ManagedEnvironment $tenant = null): array
|
||||
{
|
||||
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||
}
|
||||
|
||||
public static function currentTenantContext(): ?Tenant
|
||||
public static function currentTenantContext(): ?ManagedEnvironment
|
||||
{
|
||||
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||
public static function reviewPackGenerationBlocked(?ManagedEnvironment $tenant = null): bool
|
||||
{
|
||||
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||
public static function reviewPackGenerationBlockReason(?ManagedEnvironment $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
@ -575,7 +610,7 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
|
||||
public static function reviewPackGenerationWarningReason(?ManagedEnvironment $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
@ -588,12 +623,12 @@ public static function reviewPackGenerationWarningReason(?Tenant $tenant = null)
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||
public static function reviewPackGenerationActionTooltip(?ManagedEnvironment $tenant = null): ?string
|
||||
{
|
||||
$tenant ??= static::currentTenantContext();
|
||||
$user = auth()->user();
|
||||
|
||||
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
||||
if ($tenant instanceof ManagedEnvironment && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
||||
return AuthUiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,20 @@ class ViewReviewPack extends ViewRecord
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if (ReviewPackResource::isCustomerWorkspaceFlow()) {
|
||||
return [
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [
|
||||
'source_surface' => \App\Filament\Pages\Reviews\CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]))
|
||||
->openUrlInNewTab(),
|
||||
];
|
||||
}
|
||||
|
||||
$regenerateAction = UiEnforcement::forAction(
|
||||
Actions\Action::make('regenerate')
|
||||
->label('Regenerate')
|
||||
|
||||
670
apps/platform/app/Filament/Resources/StoredReportResource.php
Normal file
670
apps/platform/app/Filament/Resources/StoredReportResource.php
Normal file
@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\StoredReportResource\Pages;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
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 App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonInterface;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
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\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
use UnitEnum;
|
||||
|
||||
class StoredReportResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const REPORT_TYPE_CAPABILITIES = [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => Capabilities::PERMISSION_POSTURE_VIEW,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => Capabilities::ENTRA_ROLES_VIEW,
|
||||
];
|
||||
|
||||
protected static ?string $model = StoredReport::class;
|
||||
|
||||
protected static ?string $slug = 'stored-reports';
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-chart-bar';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Stored reports';
|
||||
|
||||
protected static ?int $navigationSort = 49;
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return static::visibleReportTypesForCurrentUser() !== [];
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $record instanceof StoredReport) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey() || (int) $record->workspace_id !== (int) $tenant->workspace_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$capability = static::capabilityForReportType((string) $record->report_type);
|
||||
|
||||
return $capability !== null && $user->can($capability, $tenant);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Stored reports are read-only in v1 and do not expose generation, rerun, export, or mutation actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection is the only row action for the read-only stored-report register.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stored reports do not support bulk actions in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state links back to the tenant overview without implying report generation from this surface.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Historical detail exposes the single navigation action Open current report; current detail has no header action.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = static::getTenantOwnedEloquentQuery()
|
||||
->with('tenant')
|
||||
->whereIn('report_type', static::supportedReportTypes());
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$query->where('workspace_id', (int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
$visibleReportTypes = static::visibleReportTypesForCurrentUser();
|
||||
|
||||
if ($visibleReportTypes === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn('report_type', $visibleReportTypes);
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||
{
|
||||
$query = parent::getEloquentQuery()
|
||||
->with('tenant')
|
||||
->whereIn('report_type', static::supportedReportTypes());
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$query->where('workspace_id', (int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
return static::resolveTenantOwnedRecordOrFail(
|
||||
$record,
|
||||
$query,
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Outcome summary')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.governance-artifact-truth')
|
||||
->state(fn (StoredReport $record): array => static::truthState($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Stored report')
|
||||
->schema([
|
||||
TextEntry::make('display_reference')
|
||||
->label('Artifact reference')
|
||||
->state(fn (StoredReport $record): string => static::displayReference($record)),
|
||||
TextEntry::make('report_type')
|
||||
->label('Report family')
|
||||
->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state)),
|
||||
TextEntry::make('measured_at')
|
||||
->label('Measured at')
|
||||
->state(fn (StoredReport $record): ?CarbonInterface => static::measuredAt($record))
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('lifecycle_state')
|
||||
->label('Lifecycle')
|
||||
->state(fn (StoredReport $record): string => static::lifecycleState($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::GovernanceArtifactLifecycle))
|
||||
->color(BadgeRenderer::color(BadgeDomain::GovernanceArtifactLifecycle))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::GovernanceArtifactLifecycle))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::GovernanceArtifactLifecycle)),
|
||||
TextEntry::make('retention_state')
|
||||
->label('Retention')
|
||||
->state(fn (): string => 'retained')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::GovernanceArtifactRetention))
|
||||
->color(BadgeRenderer::color(BadgeDomain::GovernanceArtifactRetention))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::GovernanceArtifactRetention))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::GovernanceArtifactRetention)),
|
||||
TextEntry::make('fingerprint')
|
||||
->label('Integrity anchor')
|
||||
->copyable()
|
||||
->fontFamily('mono')
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('previous_fingerprint')
|
||||
->label('Previous fingerprint')
|
||||
->copyable()
|
||||
->fontFamily('mono')
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Permission posture summary')
|
||||
->schema([
|
||||
TextEntry::make('payload.posture_score')->label('Posture score')->placeholder('—'),
|
||||
TextEntry::make('payload.required_count')->label('Required permissions')->placeholder('0'),
|
||||
TextEntry::make('payload.granted_count')->label('Granted permissions')->placeholder('0'),
|
||||
TextEntry::make('missing_count')
|
||||
->label('Missing permissions')
|
||||
->state(fn (StoredReport $record): int => static::permissionPostureMissingCount($record)),
|
||||
TextEntry::make('at_risk_permissions')
|
||||
->label('Missing or at-risk permission context')
|
||||
->state(fn (StoredReport $record): array => static::permissionPostureAtRiskPermissions($record))
|
||||
->bulleted()
|
||||
->listWithLineBreaks()
|
||||
->placeholder('No missing or at-risk permissions in the stored payload.')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (StoredReport $record): bool => $record->report_type === StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Entra admin roles summary')
|
||||
->schema([
|
||||
TextEntry::make('payload.totals.roles_total')->label('Roles total')->placeholder('0'),
|
||||
TextEntry::make('payload.totals.assignments_total')->label('Assignments total')->placeholder('0'),
|
||||
TextEntry::make('payload.totals.high_privilege_assignments')->label('High-privilege assignments')->placeholder('0'),
|
||||
TextEntry::make('highest_risk_assignment')
|
||||
->label('Highest-risk assignment')
|
||||
->state(fn (StoredReport $record): ?string => static::highestRiskAssignmentLabel($record))
|
||||
->placeholder('No high-privilege assignments in the stored payload.')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (StoredReport $record): bool => $record->report_type === StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Raw payload')
|
||||
->schema([
|
||||
ViewEntry::make('payload')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (StoredReport $record): array => is_array($record->payload) ? $record->payload : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->recordUrl(fn (StoredReport $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('Reference')
|
||||
->formatStateUsing(fn (int|string|null $state): string => sprintf('Stored report #%s', $state ?? '—'))
|
||||
->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)),
|
||||
Tables\Columns\TextColumn::make('report_type')
|
||||
->label('Report family')
|
||||
->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state))
|
||||
->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)),
|
||||
Tables\Columns\TextColumn::make('lifecycle_state')
|
||||
->label('Lifecycle')
|
||||
->getStateUsing(fn (StoredReport $record): string => static::lifecycleState($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::GovernanceArtifactLifecycle))
|
||||
->color(BadgeRenderer::color(BadgeDomain::GovernanceArtifactLifecycle))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::GovernanceArtifactLifecycle))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::GovernanceArtifactLifecycle)),
|
||||
Tables\Columns\TextColumn::make('measured_at')
|
||||
->label('Measured at')
|
||||
->getStateUsing(fn (StoredReport $record): ?CarbonInterface => static::measuredAt($record))
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('summary')
|
||||
->label('Summary')
|
||||
->getStateUsing(fn (StoredReport $record): string => static::summaryText($record))
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
->label('Integrity')
|
||||
->formatStateUsing(fn (?string $state): string => filled($state) ? 'Present' : '—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('report_type')
|
||||
->label('Report family')
|
||||
->options(fn (): array => static::visibleReportFamilyOptions()),
|
||||
Tables\Filters\SelectFilter::make('history')
|
||||
->label('Records')
|
||||
->options([
|
||||
'current' => 'Current records',
|
||||
'all' => 'Include history',
|
||||
])
|
||||
->default('current')
|
||||
->query(fn (Builder $query, array $data): Builder => ($data['value'] ?? 'current') === 'all'
|
||||
? $query
|
||||
: static::scopeCurrentRecords($query)),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No stored reports yet')
|
||||
->emptyStateDescription('Stored reports appear here after their origin surfaces create retained report records.')
|
||||
->emptyStateIcon('heroicon-o-document-chart-bar')
|
||||
->emptyStateActions([
|
||||
Actions\Action::make('open_tenant_overview')
|
||||
->label('Open tenant overview')
|
||||
->icon('heroicon-o-home')
|
||||
->url(fn (): string => static::tenantOverviewUrl()),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListStoredReports::route('/'),
|
||||
'view' => Pages\ViewStoredReport::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function supportedReportTypes(): array
|
||||
{
|
||||
return array_keys(self::REPORT_TYPE_CAPABILITIES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function reportFamilyOptions(): array
|
||||
{
|
||||
return [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => 'Permission posture',
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function visibleReportFamilyOptions(): array
|
||||
{
|
||||
return array_intersect_key(
|
||||
static::reportFamilyOptions(),
|
||||
array_flip(static::visibleReportTypesForCurrentUser()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function visibleReportTypesForCurrentUser(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
static::supportedReportTypes(),
|
||||
static fn (string $reportType): bool => ($capability = static::capabilityForReportType($reportType)) !== null
|
||||
&& $user->can($capability, $tenant),
|
||||
));
|
||||
}
|
||||
|
||||
public static function capabilityForReportType(string $reportType): ?string
|
||||
{
|
||||
return self::REPORT_TYPE_CAPABILITIES[$reportType] ?? null;
|
||||
}
|
||||
|
||||
public static function reportFamilyLabel(string $reportType): string
|
||||
{
|
||||
return static::reportFamilyOptions()[$reportType] ?? Str::headline($reportType);
|
||||
}
|
||||
|
||||
public static function reportFamilyReportLabel(string $reportType): string
|
||||
{
|
||||
return match ($reportType) {
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => 'Permission posture report',
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles report',
|
||||
default => Str::headline($reportType),
|
||||
};
|
||||
}
|
||||
|
||||
public static function displayReference(StoredReport $report): string
|
||||
{
|
||||
$truth = app(ArtifactTruthPresenter::class)->forStoredReportFresh($report);
|
||||
|
||||
return $truth->displayReference ?? sprintf('Stored report #%d (%s)', (int) $report->getKey(), static::reportFamilyLabel((string) $report->report_type));
|
||||
}
|
||||
|
||||
public static function lifecycleState(StoredReport $report): string
|
||||
{
|
||||
$truth = app(ArtifactTruthPresenter::class)->forStoredReportFresh($report);
|
||||
|
||||
return $truth->lifecycleState ?? 'current';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function truthState(StoredReport $report): array
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forStoredReportFresh($report)->toArray();
|
||||
}
|
||||
|
||||
public static function measuredAt(StoredReport $report): ?CarbonInterface
|
||||
{
|
||||
$payload = is_array($report->payload) ? $report->payload : [];
|
||||
$value = Arr::get($payload, 'measured_at') ?? Arr::get($payload, 'checked_at');
|
||||
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (Throwable) {
|
||||
// Fall back to persisted timestamps when payload timestamps are malformed.
|
||||
}
|
||||
}
|
||||
|
||||
return $report->created_at ?? $report->updated_at;
|
||||
}
|
||||
|
||||
public static function summaryText(StoredReport $report): string
|
||||
{
|
||||
$highlights = static::summaryHighlights($report);
|
||||
|
||||
if ($highlights === []) {
|
||||
return 'No bounded summary is available for this report family.';
|
||||
}
|
||||
|
||||
return collect($highlights)
|
||||
->map(static fn (array $highlight): string => sprintf('%s: %s', $highlight['label'], $highlight['value']))
|
||||
->implode(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, value: string}>
|
||||
*/
|
||||
public static function summaryHighlights(StoredReport $report): array
|
||||
{
|
||||
return match ((string) $report->report_type) {
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => static::permissionPostureHighlights($report),
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => static::entraAdminRolesHighlights($report),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static function permissionPostureMissingCount(StoredReport $report): int
|
||||
{
|
||||
$payload = is_array($report->payload) ? $report->payload : [];
|
||||
$requiredCount = max(0, (int) ($payload['required_count'] ?? 0));
|
||||
$grantedCount = max(0, (int) ($payload['granted_count'] ?? 0));
|
||||
|
||||
return max(0, $requiredCount - $grantedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function permissionPostureAtRiskPermissions(StoredReport $report): array
|
||||
{
|
||||
$payload = is_array($report->payload) ? $report->payload : [];
|
||||
$permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : [];
|
||||
|
||||
return collect($permissions)
|
||||
->filter(static fn (mixed $permission): bool => is_array($permission) && ($permission['status'] ?? null) !== 'granted')
|
||||
->take(5)
|
||||
->map(static function (array $permission): string {
|
||||
$key = trim((string) ($permission['key'] ?? 'Unknown permission'));
|
||||
$status = trim((string) ($permission['status'] ?? 'unknown'));
|
||||
|
||||
return sprintf('%s (%s)', $key !== '' ? $key : 'Unknown permission', $status !== '' ? $status : 'unknown');
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function highestRiskAssignmentLabel(StoredReport $report): ?string
|
||||
{
|
||||
$payload = is_array($report->payload) ? $report->payload : [];
|
||||
$assignments = is_array($payload['high_privilege'] ?? null) ? $payload['high_privilege'] : [];
|
||||
|
||||
$assignment = collect($assignments)
|
||||
->filter(static fn (mixed $assignment): bool => is_array($assignment))
|
||||
->sortBy(static fn (array $assignment): int => match ((string) ($assignment['severity'] ?? '')) {
|
||||
'critical' => 0,
|
||||
'high' => 1,
|
||||
'medium' => 2,
|
||||
'low' => 3,
|
||||
default => 4,
|
||||
})
|
||||
->first();
|
||||
|
||||
if (! is_array($assignment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$role = trim((string) ($assignment['role_display_name'] ?? 'Unknown role'));
|
||||
$principal = trim((string) ($assignment['principal_display_name'] ?? 'Unknown principal'));
|
||||
$severity = trim((string) ($assignment['severity'] ?? 'unknown'));
|
||||
|
||||
return sprintf('%s assigned to %s (%s)', $role !== '' ? $role : 'Unknown role', $principal !== '' ? $principal : 'Unknown principal', $severity !== '' ? $severity : 'unknown');
|
||||
}
|
||||
|
||||
public static function currentReportFor(StoredReport $report): ?StoredReport
|
||||
{
|
||||
$current = StoredReport::query()
|
||||
->where('managed_environment_id', (int) $report->managed_environment_id)
|
||||
->where('workspace_id', (int) $report->workspace_id)
|
||||
->where('report_type', (string) $report->report_type)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $current instanceof StoredReport && ! $current->is($report) ? $current : null;
|
||||
}
|
||||
|
||||
public static function currentReportUrlFor(StoredReport $report): ?string
|
||||
{
|
||||
$current = static::currentReportFor($report);
|
||||
|
||||
if (! $current instanceof StoredReport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $current->tenant ?? static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::getUrl('view', ['record' => $current], tenant: $tenant);
|
||||
}
|
||||
|
||||
public static function scopeCurrentRecords(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotExists(function ($subQuery): void {
|
||||
$subQuery
|
||||
->selectRaw('1')
|
||||
->from('stored_reports as newer_stored_reports')
|
||||
->whereColumn('newer_stored_reports.managed_environment_id', 'stored_reports.managed_environment_id')
|
||||
->whereColumn('newer_stored_reports.workspace_id', 'stored_reports.workspace_id')
|
||||
->whereColumn('newer_stored_reports.report_type', 'stored_reports.report_type')
|
||||
->where(function ($newerQuery): void {
|
||||
$newerQuery
|
||||
->whereColumn('newer_stored_reports.created_at', '>', 'stored_reports.created_at')
|
||||
->orWhere(function ($tieQuery): void {
|
||||
$tieQuery
|
||||
->whereColumn('newer_stored_reports.created_at', 'stored_reports.created_at')
|
||||
->whereColumn('newer_stored_reports.id', '>', 'stored_reports.id');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static function applyReportSearch(Builder $query, string $search): Builder
|
||||
{
|
||||
$search = trim($search);
|
||||
|
||||
if ($search === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$normalizedSearch = Str::lower($search);
|
||||
$matchingReportTypes = collect(static::reportFamilyOptions())
|
||||
->filter(static fn (string $label, string $reportType): bool => str_contains(Str::lower($label), $normalizedSearch)
|
||||
|| str_contains(Str::lower(static::reportFamilyReportLabel($reportType)), $normalizedSearch)
|
||||
|| str_contains(Str::lower($reportType), $normalizedSearch))
|
||||
->keys()
|
||||
->all();
|
||||
|
||||
return $query->where(function (Builder $searchQuery) use ($search, $matchingReportTypes): void {
|
||||
$searchQuery->where('report_type', 'like', '%'.$search.'%');
|
||||
|
||||
if (is_numeric($search)) {
|
||||
$searchQuery->orWhereKey((int) $search);
|
||||
}
|
||||
|
||||
if ($matchingReportTypes !== []) {
|
||||
$searchQuery->orWhereIn('report_type', $matchingReportTypes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, value: string}>
|
||||
*/
|
||||
private static function permissionPostureHighlights(StoredReport $report): array
|
||||
{
|
||||
$payload = is_array($report->payload) ? $report->payload : [];
|
||||
$postureScore = $payload['posture_score'] ?? null;
|
||||
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
||||
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
||||
|
||||
return [
|
||||
['label' => 'Posture score', 'value' => is_numeric($postureScore) ? (string) ((int) $postureScore) : '—'],
|
||||
['label' => 'Required', 'value' => (string) $requiredCount],
|
||||
['label' => 'Granted', 'value' => (string) $grantedCount],
|
||||
['label' => 'Missing', 'value' => (string) static::permissionPostureMissingCount($report)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, value: string}>
|
||||
*/
|
||||
private static function entraAdminRolesHighlights(StoredReport $report): array
|
||||
{
|
||||
$payload = is_array($report->payload) ? $report->payload : [];
|
||||
$totals = is_array($payload['totals'] ?? null) ? $payload['totals'] : [];
|
||||
|
||||
return [
|
||||
['label' => 'Roles', 'value' => (string) ((int) ($totals['roles_total'] ?? 0))],
|
||||
['label' => 'Assignments', 'value' => (string) ((int) ($totals['assignments_total'] ?? 0))],
|
||||
['label' => 'High privilege', 'value' => (string) ((int) ($totals['high_privilege_assignments'] ?? 0))],
|
||||
['label' => 'Highest risk', 'value' => static::highestRiskAssignmentLabel($report) ?? '—'],
|
||||
];
|
||||
}
|
||||
|
||||
private static function tenantOverviewUrl(): string
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
return TenantDashboard::getUrl(tenant: $tenant);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\StoredReportResource\Pages;
|
||||
|
||||
use App\Filament\Resources\StoredReportResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListStoredReports extends ListRecords
|
||||
{
|
||||
protected static string $resource = StoredReportResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\StoredReportResource\Pages;
|
||||
|
||||
use App\Filament\Resources\StoredReportResource;
|
||||
use App\Models\StoredReport;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewStoredReport extends ViewRecord
|
||||
{
|
||||
protected static string $resource = StoredReportResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return StoredReportResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('open_current_report')
|
||||
->label('Open current report')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (): ?string => $this->record instanceof StoredReport
|
||||
? StoredReportResource::currentReportUrlFor($this->record)
|
||||
: null)
|
||||
->visible(fn (): bool => $this->record instanceof StoredReport
|
||||
&& StoredReportResource::currentReportUrlFor($this->record) !== null),
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Tenants\TenantActionSurface;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
@ -28,7 +28,7 @@ protected function getHeaderActions(): array
|
||||
->label('Lifecycle')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||
&& in_array(
|
||||
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
|
||||
['archive', 'restore'],
|
||||
|
||||
@ -2,7 +2,38 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use Filament\Actions\Action;
|
||||
|
||||
class ManageTenantMemberships extends ViewTenant
|
||||
{
|
||||
protected static ?string $title = 'Tenant memberships';
|
||||
protected static ?string $title = 'Manage tenant memberships';
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return 'ManagedEnvironment access is managed here. Use the tenant overview for provider state, verification, and operational context.';
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = array_values(array_filter(
|
||||
parent::getHeaderActions(),
|
||||
static fn ($action): bool => ! ($action instanceof Action && $action->getName() === 'memberships'),
|
||||
));
|
||||
|
||||
array_unshift(
|
||||
$actions,
|
||||
Action::make('back_to_overview')
|
||||
->label('Back to tenant overview')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $this->getRecord()->getRouteKey()], panel: 'admin')),
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -54,6 +54,7 @@ protected function getHeaderWidgets(): array
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
TenantResource::makeMembershipsAction(),
|
||||
Actions\ActionGroup::make([
|
||||
TenantResource::makeAdminConsentAction(),
|
||||
TenantResource::makeOpenInEntraAction(),
|
||||
@ -61,7 +62,7 @@ protected function getHeaderActions(): array
|
||||
->label('External links')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
TenantResource::makeSyncTenantAction(),
|
||||
@ -73,8 +74,8 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->action(function (Tenant $record): void {
|
||||
->visible(fn (ManagedEnvironment $record): bool => TenantResource::tenantSetupMutationVisible($record))
|
||||
->action(function (ManagedEnvironment $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -92,7 +93,7 @@ protected function getHeaderActions(): array
|
||||
tenant: $record,
|
||||
type: OperationRunType::RbacHealthCheck->value,
|
||||
inputs: [
|
||||
'tenant_id' => (int) $record->getKey(),
|
||||
'managed_environment_id' => (int) $record->getKey(),
|
||||
'surface' => 'tenant_view_header',
|
||||
],
|
||||
initiator: $user,
|
||||
@ -138,7 +139,7 @@ protected function getHeaderActions(): array
|
||||
->label('Setup')
|
||||
->icon('heroicon-o-wrench-screwdriver')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
TenantResource::makeTenantViewMarkReviewedAction(),
|
||||
@ -147,16 +148,18 @@ protected function getHeaderActions(): array
|
||||
->label('Triage')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||
&& TenantResource::tenantViewTriageGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
|
||||
TenantResource::makeRestoreTenantToWorkspaceAction(),
|
||||
TenantResource::makeRemoveTenantFromWorkspaceAction(),
|
||||
TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
|
||||
])
|
||||
->label('Lifecycle')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
|
||||
]));
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
@ -30,7 +30,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant membership rows are managed inline and have no separate inspect destination.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'ManagedEnvironment membership rows are managed inline and have no separate inspect destination.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Change role and remove stay direct for focused inline membership management.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||
@ -38,7 +38,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
|
||||
{
|
||||
if (! $ownerRecord instanceof Tenant) {
|
||||
if (! $ownerRecord instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ public function table(Table $table): Table
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('user_domain')
|
||||
->label(__('Domain'))
|
||||
->getStateUsing(function (TenantMembership $record): ?string {
|
||||
->getStateUsing(function (ManagedEnvironmentMembership $record): ?string {
|
||||
$email = $record->user?->email;
|
||||
|
||||
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||
@ -124,7 +124,7 @@ public function table(Table $table): Table
|
||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -184,10 +184,10 @@ public function table(Table $table): Table
|
||||
'readonly' => __('Readonly'),
|
||||
]),
|
||||
])
|
||||
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
||||
->action(function (ManagedEnvironmentMembership $record, array $data, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -228,10 +228,10 @@ public function table(Table $table): Table
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
||||
->action(function (ManagedEnvironmentMembership $record, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@ -6,11 +6,13 @@
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\TenantReviewSection;
|
||||
use App\Models\User;
|
||||
@ -26,6 +28,7 @@
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
@ -46,6 +49,7 @@
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Panel;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -61,6 +65,7 @@ class TenantReviewResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use WorkspaceScopedTenantRoutes;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
@ -82,7 +87,16 @@ class TenantReviewResource extends Resource
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function getSlug(?Panel $panel = null): string
|
||||
{
|
||||
$slug = $panel?->getId() === 'admin'
|
||||
? 'tenant-reviews'
|
||||
: parent::getSlug($panel);
|
||||
|
||||
return static::workspaceScopedSlug($slug, $panel);
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
@ -110,7 +124,7 @@ public static function canViewAny(): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -126,7 +140,7 @@ public static function canView(Model $record): bool
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $record instanceof TenantReview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -134,7 +148,7 @@ public static function canView(Model $record): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -147,7 +161,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->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::ListBulkMoreGroup, 'ManagedEnvironment reviews do not expose bulk actions in the first slice.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes one dominant lifecycle action, groups the remaining lifecycle actions under "More", keeps archive in a danger bucket, and renders operation/export/evidence navigation in contextual summary content.');
|
||||
}
|
||||
@ -215,6 +229,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceMode())
|
||||
->columnSpanFull()
|
||||
->fontFamily('mono')
|
||||
->size(TextSize::ExtraSmall),
|
||||
@ -233,6 +248,7 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make(__('localization.review.sections'))
|
||||
->schema([
|
||||
RepeatableEntry::make('sections')
|
||||
->state(fn (TenantReview $record): array => static::visibleSections($record))
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('title'),
|
||||
@ -262,6 +278,17 @@ public static function infolist(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, TenantReviewSection>
|
||||
*/
|
||||
private static function visibleSections(TenantReview $record): array
|
||||
{
|
||||
return $record->sections
|
||||
->reject(fn (TenantReviewSection $section): bool => static::isCustomerWorkspaceMode() && $section->isControlInterpretation())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||
@ -386,10 +413,10 @@ public static function makeCreateReviewAction(
|
||||
*/
|
||||
public static function executeCreateReview(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
|
||||
|
||||
return;
|
||||
@ -407,7 +434,7 @@ public static function executeCreateReview(array $data): void
|
||||
$snapshot = is_numeric($snapshotId)
|
||||
? EvidenceSnapshot::query()
|
||||
->whereKey((int) $snapshotId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->first()
|
||||
: null;
|
||||
|
||||
@ -459,23 +486,23 @@ public static function executeCreateReview(array $data): void
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||
public static function reviewPackGenerationDecision(?ManagedEnvironment $tenant = null): array
|
||||
{
|
||||
$tenant ??= Filament::getTenant();
|
||||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||
public static function reviewPackGenerationBlocked(?ManagedEnvironment $tenant = null): bool
|
||||
{
|
||||
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||
public static function reviewPackGenerationBlockReason(?ManagedEnvironment $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
@ -488,7 +515,7 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
|
||||
public static function reviewPackGenerationWarningReason(?ManagedEnvironment $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
@ -501,12 +528,12 @@ public static function reviewPackGenerationWarningReason(?Tenant $tenant = null)
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||
public static function reviewPackGenerationActionTooltip(?ManagedEnvironment $tenant = null): ?string
|
||||
{
|
||||
$tenant ??= static::panelTenantContext();
|
||||
$user = auth()->user();
|
||||
|
||||
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||
if ($tenant instanceof ManagedEnvironment && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||
return AuthUiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
@ -519,7 +546,7 @@ public static function executeExport(TenantReview $review): void
|
||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $review->tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
|
||||
|
||||
return;
|
||||
@ -587,10 +614,10 @@ public static function executeExport(TenantReview $review): void
|
||||
public static function tenantScopedUrl(
|
||||
string $page = 'index',
|
||||
array $parameters = [],
|
||||
?Tenant $tenant = null,
|
||||
?ManagedEnvironment $tenant = null,
|
||||
?string $panel = null,
|
||||
): string {
|
||||
$panelId = $panel ?? 'tenant';
|
||||
$panelId = 'admin';
|
||||
|
||||
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
||||
}
|
||||
@ -600,14 +627,14 @@ public static function tenantScopedUrl(
|
||||
*/
|
||||
private static function evidenceSnapshotOptions(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->whereNotNull('generated_at')
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
@ -639,6 +666,10 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
||||
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||
$controlInterpretation = is_array($summary['control_interpretation'] ?? null)
|
||||
? $summary['control_interpretation']
|
||||
: [];
|
||||
$packagePresentation = static::governancePackagePresentation($record);
|
||||
|
||||
if ($findingOutcomeSummary !== null) {
|
||||
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
||||
@ -647,12 +678,17 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
return [
|
||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'customer_workspace_mode' => static::isCustomerWorkspaceMode(),
|
||||
'reason_semantics' => static::isCustomerWorkspaceMode()
|
||||
? []
|
||||
: $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'highlights' => $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'] : [],
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
'metrics' => [
|
||||
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
|
||||
'control_interpretation' => $controlInterpretation,
|
||||
'governance_package' => $packagePresentation,
|
||||
'metrics' => static::isCustomerWorkspaceMode() ? static::customerWorkspaceMetrics($record, $summary, $packagePresentation) : [
|
||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
@ -664,13 +700,164 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
||||
* @param array<string, mixed> $summary
|
||||
* @param array<string, mixed> $packagePresentation
|
||||
* @return array<int, array{label:string,value:string}>
|
||||
*/
|
||||
private static function summaryContextLinks(TenantReview $record): array
|
||||
private static function customerWorkspaceMetrics(TenantReview $record, array $summary, array $packagePresentation): array
|
||||
{
|
||||
$acceptedRisk = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
||||
|
||||
return [
|
||||
['label' => __('localization.review.governance_package'), 'value' => (string) ($packagePresentation['availability']['label'] ?? __('localization.review.governance_package_unavailable'))],
|
||||
['label' => __('localization.review.review_status'), 'value' => static::customerReviewStatusLabel($record)],
|
||||
['label' => __('localization.review.evidence_status'), 'value' => static::customerEvidenceStatusLabel($record)],
|
||||
['label' => __('localization.review.accepted_risk_status'), 'value' => static::customerAcceptedRiskStatusLabel($acceptedRisk)],
|
||||
['label' => __('localization.review.last_review'), 'value' => $record->published_at?->format('Y-m-d') ?? __('localization.review.pending')],
|
||||
];
|
||||
}
|
||||
|
||||
private static function customerReviewStatusLabel(TenantReview $record): string
|
||||
{
|
||||
if ($record->isPublished() && (string) $record->completeness_state === TenantReviewCompletenessState::Complete->value) {
|
||||
return __('localization.review.review_completed');
|
||||
}
|
||||
|
||||
if ($record->isPublished()) {
|
||||
return __('localization.review.review_requires_attention');
|
||||
}
|
||||
|
||||
return Str::headline((string) $record->status);
|
||||
}
|
||||
|
||||
private static function customerEvidenceStatusLabel(TenantReview $record): string
|
||||
{
|
||||
$snapshot = $record->evidenceSnapshot;
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return __('localization.review.evidence_pending');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return __('localization.review.evidence_restricted');
|
||||
}
|
||||
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return __('localization.review.evidence_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.evidence_available');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $acceptedRisk
|
||||
*/
|
||||
private static function customerAcceptedRiskStatusLabel(array $acceptedRisk): string
|
||||
{
|
||||
$warningCount = (int) ($acceptedRisk['warning_count'] ?? 0);
|
||||
$statusMarkedCount = (int) ($acceptedRisk['status_marked_count'] ?? 0);
|
||||
|
||||
if ($warningCount > 0) {
|
||||
return __('localization.review.accepted_risk_follow_up');
|
||||
}
|
||||
|
||||
if ($statusMarkedCount > 0) {
|
||||
return __('localization.review.accepted_risk_on_record', ['count' => $statusMarkedCount]);
|
||||
}
|
||||
|
||||
return __('localization.review.accepted_risk_none');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function governancePackagePresentation(TenantReview $record): array
|
||||
{
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
||||
|
||||
if ($package === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_merge($package, [
|
||||
'availability' => static::governancePackageAvailability($record),
|
||||
'delivery_note' => __('localization.review.governance_package_delivery_note'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,label:string,description:string}
|
||||
*/
|
||||
private static function governancePackageAvailability(TenantReview $record): array
|
||||
{
|
||||
$pack = $record->currentExportReviewPack;
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
$controlInterpretation = $record->controlInterpretation();
|
||||
$limitations = is_array($controlInterpretation['limitations'] ?? null) ? $controlInterpretation['limitations'] : [];
|
||||
$isPartialReview = in_array((string) $record->completeness_state, [
|
||||
TenantReviewCompletenessState::Partial->value,
|
||||
TenantReviewCompletenessState::Stale->value,
|
||||
], true) || $limitations !== [];
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_unavailable_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return [
|
||||
'state' => 'blocked',
|
||||
'label' => __('localization.review.governance_package_blocked'),
|
||||
'description' => __('localization.review.governance_package_blocked_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
|
||||
return [
|
||||
'state' => 'expired',
|
||||
'label' => __('localization.review.governance_package_expired'),
|
||||
'description' => __('localization.review.governance_package_expired_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_not_ready_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPartialReview) {
|
||||
return [
|
||||
'state' => 'partial',
|
||||
'label' => __('localization.review.governance_package_partial'),
|
||||
'description' => __('localization.review.governance_package_partial_description'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'available',
|
||||
'label' => __('localization.review.governance_package_available'),
|
||||
'description' => __('localization.review.governance_package_available_description'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,label:string,url:?string,description:string}>
|
||||
*/
|
||||
private static function summaryContextLinks(TenantReview $record, bool $customerWorkspaceMode = false): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.operation'),
|
||||
'label' => __('localization.review.open_operation'),
|
||||
@ -679,7 +866,7 @@ private static function summaryContextLinks(TenantReview $record): array
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->currentExportReviewPack && $record->tenant) {
|
||||
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.executive_pack'),
|
||||
'label' => __('localization.review.view_executive_pack'),
|
||||
@ -698,11 +885,23 @@ private static function summaryContextLinks(TenantReview $record): array
|
||||
}
|
||||
|
||||
if ($record->evidenceSnapshot && $record->tenant) {
|
||||
$user = auth()->user();
|
||||
$canViewEvidence = $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $record->tenant);
|
||||
$evidenceUrl = $canViewEvidence
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null;
|
||||
|
||||
if ($customerWorkspaceMode && $evidenceUrl !== null) {
|
||||
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($record));
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'title' => __('localization.review.evidence_snapshot'),
|
||||
'label' => __('localization.review.view_evidence_snapshot'),
|
||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||
'description' => __('localization.review.evidence_snapshot_description'),
|
||||
'url' => $evidenceUrl,
|
||||
'description' => $canViewEvidence
|
||||
? __('localization.review.evidence_snapshot_description')
|
||||
: __('localization.review.evidence_proof_access_unavailable'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -718,6 +917,24 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
$review = $section->tenantReview;
|
||||
$tenant = $section->tenant;
|
||||
$links = [];
|
||||
|
||||
if ($section->isControlInterpretation() && $review instanceof TenantReview && $tenant instanceof ManagedEnvironment && $review->evidenceSnapshot instanceof EvidenceSnapshot) {
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
$evidenceUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $tenant);
|
||||
|
||||
if (static::isCustomerWorkspaceMode()) {
|
||||
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($review));
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'label' => __('localization.review.view_evidence_snapshot'),
|
||||
'url' => $evidenceUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
||||
@ -735,7 +952,8 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
||||
'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' => [],
|
||||
'is_control_interpretation' => $section->isControlInterpretation(),
|
||||
'links' => $links,
|
||||
];
|
||||
}
|
||||
|
||||
@ -783,4 +1001,34 @@ private static function findingOutcomeSummary(array $summary): ?string
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
|
||||
private static function isCustomerWorkspaceMode(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function customerWorkspaceEvidenceQuery(TenantReview $record): array
|
||||
{
|
||||
return array_filter([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => (int) $record->getKey(),
|
||||
'interpretation_version' => $record->controlInterpretationVersion(),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,15 +6,18 @@
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions;
|
||||
@ -45,11 +48,11 @@ protected function authorizeAccess(): void
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $record instanceof TenantReview) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@ -64,6 +67,12 @@ protected function authorizeAccess(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if ($this->isCustomerWorkspaceView()) {
|
||||
return [
|
||||
$this->downloadCurrentReviewPackAction(),
|
||||
];
|
||||
}
|
||||
|
||||
$secondaryActions = $this->secondaryLifecycleActions();
|
||||
|
||||
return array_values(array_filter([
|
||||
@ -343,6 +352,77 @@ private function archiveReviewAction(): Actions\Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function downloadCurrentReviewPackAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('download_current_review_pack')
|
||||
->label(__('localization.review.download_governance_package'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('primary')
|
||||
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
|
||||
->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason())
|
||||
->url(fn (): ?string => $this->currentReviewPackDownloadUrl())
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
|
||||
private function currentReviewPackDownloadUrl(): ?string
|
||||
{
|
||||
$pack = $this->record->currentExportReviewPack;
|
||||
$tenant = $this->record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack || ! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => (int) $this->record->getKey(),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => $this->record->controlInterpretationVersion(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentReviewPackUnavailableReason(): ?string
|
||||
{
|
||||
if ($this->currentReviewPackDownloadUrl() !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pack = $this->record->currentExportReviewPack;
|
||||
$tenant = $this->record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.customer_review_pack_missing');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return __('localization.review.customer_review_pack_forbidden');
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.customer_review_pack_not_ready');
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.customer_review_pack_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.customer_review_pack_unavailable');
|
||||
}
|
||||
|
||||
private function isCustomerWorkspaceView(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
@ -357,7 +437,7 @@ private function auditCustomerWorkspaceOpen(): void
|
||||
$user = auth()->user();
|
||||
$tenant = $this->record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -367,13 +447,15 @@ private function auditCustomerWorkspaceOpen(): void
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $this->record->getKey(),
|
||||
'source_surface' => 'customer_review_workspace',
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => $this->record->controlInterpretationVersion(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $this->record->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
|
||||
targetLabel: sprintf('ManagedEnvironment review #%d', (int) $this->record->getKey()),
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user