Compare commits
92 Commits
dev
...
311-worksp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fcf7b3e76 | ||
|
|
e1cfb25de6 | ||
|
|
fa3b5f6d6a | ||
| 52bb4a0afc | |||
|
|
3654d89db9 | ||
| dd175c16a1 | |||
|
|
adb3737298 | ||
| 77c343fb35 | |||
|
|
ca30ca95bf | ||
| e36574452a | |||
| ba0b6ec07e | |||
| f24e72269c | |||
| 5248654691 | |||
| 1cd8d48474 | |||
| d072b0107b | |||
| 3a30b9060c | |||
| 292d555eac | |||
| b98bafcf86 | |||
| 5722c4f051 | |||
| 3ec582a182 | |||
|
|
928d49b5fd | ||
| 38523814c2 | |||
|
|
eca923649e | ||
| f03555eae1 | |||
|
|
eb85b76eed | ||
| d3158f5103 | |||
| 83ab4690d5 | |||
| 0a1377c5f5 | |||
| eceeee9c5c | |||
| aeef285d1d | |||
| c7b38606a9 | |||
| 75ebade345 | |||
| 1debe40ced | |||
| 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 |
295
.codex/skills/browsertest/SKILL.md
Normal file
295
.codex/skills/browsertest/SKILL.md
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
---
|
||||||
|
name: browsertest
|
||||||
|
description: Führe einen vollständigen Smoke-Browser-Test im Integrated Browser für das aktuelle Feature aus, inklusive Happy Path, zentraler Regressionen, Kontext-Prüfung und belastbarer Ergebniszusammenfassung.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: GitHub Copilot
|
||||||
|
---
|
||||||
|
|
||||||
|
# Browser Smoke Test
|
||||||
|
|
||||||
|
## What This Skill Does
|
||||||
|
|
||||||
|
Use this skill to validate the current feature end-to-end in the integrated browser.
|
||||||
|
|
||||||
|
This is a focused smoke test, not a full exploratory test session. The goal is to prove that the primary operator flow:
|
||||||
|
|
||||||
|
- loads in the correct auth, workspace, and tenant context
|
||||||
|
- exposes the expected controls and decision points
|
||||||
|
- completes the main happy path without blocking issues
|
||||||
|
- lands in the expected end state or canonical drilldown
|
||||||
|
- does not show obvious regressions such as broken navigation, missing data, or conflicting actions
|
||||||
|
|
||||||
|
The skill should produce a concrete pass or fail result with actionable evidence.
|
||||||
|
|
||||||
|
## When To Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- the user asks to smoke test the current feature in the browser
|
||||||
|
- a new Filament page, dashboard signal, report, wizard, or detail flow was just added
|
||||||
|
- a UI regression fix needs confirmation in a real browser context
|
||||||
|
- the primary question is whether the feature works from an operator perspective
|
||||||
|
- you need a quick integration-level check without writing a full browser test suite first
|
||||||
|
|
||||||
|
## What Success Looks Like
|
||||||
|
|
||||||
|
A successful smoke test confirms all of the following:
|
||||||
|
|
||||||
|
- the target route opens successfully
|
||||||
|
- the visible context is correct
|
||||||
|
- the main flow is usable
|
||||||
|
- the expected result appears after interaction
|
||||||
|
- the route or drilldown destination is correct
|
||||||
|
- the surface does not obviously violate its intended interaction model
|
||||||
|
|
||||||
|
If the test cannot be completed, the output must clearly state whether the blocker is:
|
||||||
|
|
||||||
|
- authentication
|
||||||
|
- missing data or fixture state
|
||||||
|
- routing
|
||||||
|
- UI interaction failure
|
||||||
|
- server error
|
||||||
|
- an unclear expected behavior contract
|
||||||
|
|
||||||
|
Do not guess. If the route or state is blocked, report the blocker explicitly.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
Before running the browser smoke test, make sure you know:
|
||||||
|
|
||||||
|
- the canonical route or entry point for the feature
|
||||||
|
- the primary operator action or happy path
|
||||||
|
- the expected success state
|
||||||
|
- whether the feature depends on a specific tenant, workspace, or seeded record
|
||||||
|
|
||||||
|
When available, use the feature spec, quickstart, tasks, or current browser page as the source of truth.
|
||||||
|
|
||||||
|
## Standard Workflow
|
||||||
|
|
||||||
|
### 1. Define the smoke-test scope
|
||||||
|
|
||||||
|
Identify:
|
||||||
|
|
||||||
|
- the route to open
|
||||||
|
- the primary action to perform
|
||||||
|
- the expected end state
|
||||||
|
- one or two critical regressions that must not break
|
||||||
|
|
||||||
|
The smoke test should stay narrow. Prefer one complete happy path plus one critical boundary over broad exploratory clicking.
|
||||||
|
|
||||||
|
### 2. Establish the browser state
|
||||||
|
|
||||||
|
- Reuse the current browser page if it already matches the target feature.
|
||||||
|
- Otherwise open the canonical route.
|
||||||
|
- Confirm the current auth and scope context before interacting.
|
||||||
|
|
||||||
|
For this repo, that usually means checking whether the page is on:
|
||||||
|
|
||||||
|
- `/admin/...` for workspace-context surfaces
|
||||||
|
- `/admin/t/{tenant}/...` for tenant-context surfaces
|
||||||
|
|
||||||
|
### 3. Inspect before acting
|
||||||
|
|
||||||
|
- Use `read_page` before interacting so you understand the live controls, refs, headings, and route context.
|
||||||
|
- Prefer `read_page` over screenshots for actual interaction planning.
|
||||||
|
- Use screenshots only for visual evidence or when the user asks for them.
|
||||||
|
|
||||||
|
### 4. Execute the primary happy path
|
||||||
|
|
||||||
|
Run the smallest meaningful flow that proves the feature works.
|
||||||
|
|
||||||
|
Typical steps include:
|
||||||
|
|
||||||
|
- open the page
|
||||||
|
- verify heading or key summary text
|
||||||
|
- click the primary CTA or row
|
||||||
|
- fill the minimum required form fields
|
||||||
|
- confirm modal or dialog text when relevant
|
||||||
|
- submit or navigate
|
||||||
|
- verify the expected destination or changed state
|
||||||
|
|
||||||
|
After each meaningful action, re-read the page so the next step is based on current DOM state.
|
||||||
|
|
||||||
|
### 5. Validate the outcome
|
||||||
|
|
||||||
|
Check the exact result that matters for the feature.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- a new row appears
|
||||||
|
- a status changes
|
||||||
|
- a success message appears
|
||||||
|
- a report filter changes the result set
|
||||||
|
- a row click lands on the canonical detail page
|
||||||
|
- a dashboard signal links to the correct report page
|
||||||
|
|
||||||
|
### 6. Check for obvious regressions
|
||||||
|
|
||||||
|
Even in a smoke test, verify a few core non-negotiables:
|
||||||
|
|
||||||
|
- the page is not blank or half-rendered
|
||||||
|
- the main action is present and usable
|
||||||
|
- the visible context is correct
|
||||||
|
- the drilldown destination is canonical
|
||||||
|
- no obviously duplicated primary actions exist
|
||||||
|
- no stuck modal, spinner, or blocked interaction remains onscreen
|
||||||
|
|
||||||
|
### 7. Capture evidence and summarize clearly
|
||||||
|
|
||||||
|
Your result should state:
|
||||||
|
|
||||||
|
- route tested
|
||||||
|
- context used
|
||||||
|
- steps executed
|
||||||
|
- pass or fail
|
||||||
|
- exact blocker or discrepancy if failed
|
||||||
|
|
||||||
|
Include a screenshot only when it adds value.
|
||||||
|
|
||||||
|
## Tool Usage Guidance
|
||||||
|
|
||||||
|
Use the browser tools in this order by default:
|
||||||
|
|
||||||
|
1. `read_page`
|
||||||
|
2. `click_element`
|
||||||
|
3. `type_in_page`
|
||||||
|
4. `handle_dialog` when needed
|
||||||
|
5. `navigate_page` or `open_browser_page` only when route changes are required
|
||||||
|
6. `run_playwright_code` only if the normal browser tools are insufficient
|
||||||
|
7. `screenshot_page` for evidence, not for primary navigation logic
|
||||||
|
|
||||||
|
## Repo-Specific Guidance For TenantPilot
|
||||||
|
|
||||||
|
### Workspace surfaces
|
||||||
|
|
||||||
|
For `/admin` pages and similar workspace-context surfaces:
|
||||||
|
|
||||||
|
- verify the page is reachable without forcing tenant-route assumptions
|
||||||
|
- confirm any summary signal or CTA lands on the canonical destination
|
||||||
|
- verify calm-state versus attention-state behavior when the feature defines both
|
||||||
|
|
||||||
|
### Tenant surfaces
|
||||||
|
|
||||||
|
For `/admin/t/{tenant}/...` pages:
|
||||||
|
|
||||||
|
- verify the tenant context is explicit and correct
|
||||||
|
- verify drilldowns stay in the intended tenant scope
|
||||||
|
- treat cross-tenant leakage or silent scope changes as failures
|
||||||
|
|
||||||
|
### Filament list or report surfaces
|
||||||
|
|
||||||
|
For Filament tables, reports, or registry-style pages:
|
||||||
|
|
||||||
|
- verify the heading and table shell render
|
||||||
|
- verify fixed filters or summary controls exist when the spec requires them
|
||||||
|
- verify row click or the primary inspect affordance behaves as designed
|
||||||
|
- verify empty-state messaging is specific rather than generic when the feature defines custom behavior
|
||||||
|
|
||||||
|
### Filament detail pages
|
||||||
|
|
||||||
|
For detail or view surfaces:
|
||||||
|
|
||||||
|
- verify the canonical record loads
|
||||||
|
- verify expected sections or summary content are present
|
||||||
|
- verify critical actions or drillbacks are usable
|
||||||
|
|
||||||
|
## Result Format
|
||||||
|
|
||||||
|
Use a compact result format like this:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser smoke result: PASS
|
||||||
|
Route: /admin/findings/hygiene
|
||||||
|
Context: workspace member with visible hygiene issues
|
||||||
|
Steps: opened report -> verified filters -> clicked finding row -> landed on canonical finding detail
|
||||||
|
Verified: report rendered, primary interaction worked, drilldown route was correct
|
||||||
|
```
|
||||||
|
|
||||||
|
If the test fails:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser smoke result: FAIL
|
||||||
|
Route: /admin/findings/hygiene
|
||||||
|
Context: authenticated workspace member
|
||||||
|
Failed step: clicking the summary CTA
|
||||||
|
Expected: navigate to /admin/findings/hygiene
|
||||||
|
Actual: remained on /admin with no route change
|
||||||
|
Blocker: CTA appears rendered but is not interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Smoke test a new report page
|
||||||
|
|
||||||
|
Use this when the feature adds a new read-only report.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
- open the canonical report route
|
||||||
|
- verify the page heading and main controls
|
||||||
|
- confirm the table or defined empty state is visible
|
||||||
|
- click one row or primary inspect affordance
|
||||||
|
- verify navigation lands on the canonical detail route
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- report loads
|
||||||
|
- intended controls exist
|
||||||
|
- primary inspect path works
|
||||||
|
|
||||||
|
### Example 2: Smoke test a dashboard signal
|
||||||
|
|
||||||
|
Use this when the feature adds a summary signal on `/admin`.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
- open `/admin`
|
||||||
|
- find the signal
|
||||||
|
- verify the visible count or summary text
|
||||||
|
- click the CTA
|
||||||
|
- confirm navigation lands on the canonical downstream surface
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- signal is visible in the correct state
|
||||||
|
- CTA text is present
|
||||||
|
- CTA opens the correct route
|
||||||
|
|
||||||
|
### Example 3: Smoke test a tenant detail follow-up
|
||||||
|
|
||||||
|
Use this when a workspace-level surface should drill into a tenant-level detail page.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
- open the workspace-level surface
|
||||||
|
- trigger the drilldown
|
||||||
|
- verify the target route includes the correct tenant and record
|
||||||
|
- confirm the target page actually loads the expected detail content
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- drilldown route is canonical
|
||||||
|
- tenant context is correct
|
||||||
|
- destination content matches the selected record
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Clicking before reading the page state and refs
|
||||||
|
- Treating a blocked auth session as a feature failure
|
||||||
|
- Confusing workspace-context routes with tenant-context routes
|
||||||
|
- Reporting visual impressions without validating the actual interaction result
|
||||||
|
- Forgetting to re-read the page after a modal opens or a route changes
|
||||||
|
- Claiming success without verifying the final destination or changed state
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This skill does not replace:
|
||||||
|
|
||||||
|
- full exploratory QA
|
||||||
|
- formal Pest browser coverage
|
||||||
|
- accessibility review
|
||||||
|
- visual regression approval
|
||||||
|
- backend correctness tests
|
||||||
|
|
||||||
|
It is a fast, real-browser confidence pass for the current feature.
|
||||||
8
.codex/skills/giteaflow/SKILL.md
Normal file
8
.codex/skills/giteaflow/SKILL.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
name: giteaflow
|
||||||
|
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||||
|
|
||||||
|
comit all changes, push to remote, and create a pull request against platform-dev with gitea mcp
|
||||||
167
.codex/skills/pest-testing/SKILL.md
Normal file
167
.codex/skills/pest-testing/SKILL.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
name: pest-testing
|
||||||
|
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pest Testing 4
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Creating new tests (unit, feature, or browser)
|
||||||
|
- Modifying existing tests
|
||||||
|
- Debugging test failures
|
||||||
|
- Working with browser testing or smoke testing
|
||||||
|
- Writing architecture tests or visual regression tests
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating Tests
|
||||||
|
|
||||||
|
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Browser tests: `tests/Browser/` directory.
|
||||||
|
- Do NOT remove tests without approval - these are core application code.
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
<!-- Basic Pest Test Example -->
|
||||||
|
```php
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||||
|
- Run all tests: `php artisan test --compact`.
|
||||||
|
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||||
|
|
||||||
|
<!-- Pest Response Assertion -->
|
||||||
|
```php
|
||||||
|
it('returns all', function () {
|
||||||
|
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Use | Instead of |
|
||||||
|
|-----|------------|
|
||||||
|
| `assertSuccessful()` | `assertStatus(200)` |
|
||||||
|
| `assertNotFound()` | `assertStatus(404)` |
|
||||||
|
| `assertForbidden()` | `assertStatus(403)` |
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||||
|
|
||||||
|
## Datasets
|
||||||
|
|
||||||
|
Use datasets for repetitive tests (validation rules, etc.):
|
||||||
|
|
||||||
|
<!-- Pest Dataset Example -->
|
||||||
|
```php
|
||||||
|
it('has emails', function (string $email) {
|
||||||
|
expect($email)->not->toBeEmpty();
|
||||||
|
})->with([
|
||||||
|
'james' => 'james@laravel.com',
|
||||||
|
'taylor' => 'taylor@laravel.com',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pest 4 Features
|
||||||
|
|
||||||
|
| Feature | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| Browser Testing | Full integration tests in real browsers |
|
||||||
|
| Smoke Testing | Validate multiple pages quickly |
|
||||||
|
| Visual Regression | Compare screenshots for visual changes |
|
||||||
|
| Test Sharding | Parallel CI runs |
|
||||||
|
| Architecture Testing | Enforce code conventions |
|
||||||
|
|
||||||
|
### Browser Test Example
|
||||||
|
|
||||||
|
Browser tests run in real browsers for full integration testing:
|
||||||
|
|
||||||
|
- Browser tests live in `tests/Browser/`.
|
||||||
|
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||||
|
- Use `RefreshDatabase` for clean state per test.
|
||||||
|
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||||
|
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||||
|
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||||
|
- Switch color schemes (light/dark mode) when appropriate.
|
||||||
|
- Take screenshots or pause tests for debugging.
|
||||||
|
|
||||||
|
<!-- Pest Browser Test Example -->
|
||||||
|
```php
|
||||||
|
it('may reset the password', function () {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$page = visit('/sign-in');
|
||||||
|
|
||||||
|
$page->assertSee('Sign In')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->click('Forgot Password?')
|
||||||
|
->fill('email', 'nuno@laravel.com')
|
||||||
|
->click('Send Reset Link')
|
||||||
|
->assertSee('We have emailed your password reset link!');
|
||||||
|
|
||||||
|
Notification::assertSent(ResetPassword::class);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smoke Testing
|
||||||
|
|
||||||
|
Quickly validate multiple pages have no JavaScript errors:
|
||||||
|
|
||||||
|
<!-- Pest Smoke Testing Example -->
|
||||||
|
```php
|
||||||
|
$pages = visit(['/', '/about', '/contact']);
|
||||||
|
|
||||||
|
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Regression Testing
|
||||||
|
|
||||||
|
Capture and compare screenshots to detect visual changes.
|
||||||
|
|
||||||
|
### Test Sharding
|
||||||
|
|
||||||
|
Split tests across parallel processes for faster CI runs.
|
||||||
|
|
||||||
|
### Architecture Testing
|
||||||
|
|
||||||
|
Pest 4 includes architecture testing (from Pest 3):
|
||||||
|
|
||||||
|
<!-- Architecture Test Example -->
|
||||||
|
```php
|
||||||
|
arch('controllers')
|
||||||
|
->expect('App\Http\Controllers')
|
||||||
|
->toExtendNothing()
|
||||||
|
->toHaveSuffix('Controller');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||||
|
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||||
|
- Forgetting datasets for repetitive validation tests
|
||||||
|
- Deleting tests without approval
|
||||||
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
625
.codex/skills/platform-feature-finish/SKILL.md
Normal file
625
.codex/skills/platform-feature-finish/SKILL.md
Normal file
@ -0,0 +1,625 @@
|
|||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
name: platform-feature-finish
|
||||||
|
description: Commit, push, create a Gitea PR from a TenantPilot platform feature branch into platform-dev, and optionally refresh the platform-dev to dev integration PR by rebase.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: platform-feature-finish
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Automate the TenantPilot platform feature completion workflow.
|
||||||
|
|
||||||
|
Trigger this skill when the user says something like:
|
||||||
|
|
||||||
|
- "alles committen pushen und PR gegen platform-dev"
|
||||||
|
- "feature fertig, bitte PR erstellen"
|
||||||
|
- "platform feature abschließen"
|
||||||
|
- "commit push PR mit Gitea MCP"
|
||||||
|
- "mach PR gegen platform-dev"
|
||||||
|
- "finish platform feature"
|
||||||
|
- "platform-dev nach dev vorbereiten"
|
||||||
|
- "platform-dev PR aktualisieren"
|
||||||
|
- "out-of-date mit dev beheben"
|
||||||
|
- "integration PR refresh"
|
||||||
|
- "platform-dev auf dev rebasen"
|
||||||
|
|
||||||
|
This skill handles:
|
||||||
|
|
||||||
|
1. Validate current Git branch
|
||||||
|
2. Commit all feature changes
|
||||||
|
3. Push current feature branch
|
||||||
|
4. Create a Gitea pull request into `platform-dev`
|
||||||
|
5. Refresh the `platform-dev` → `dev` integration PR when explicitly requested
|
||||||
|
6. Report the PR link and next integration step
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Model
|
||||||
|
|
||||||
|
TenantPilot uses area branches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dev = shared integration branch
|
||||||
|
platform-dev = platform/application area integration branch
|
||||||
|
website-dev = website/marketing area integration branch
|
||||||
|
```
|
||||||
|
|
||||||
|
For platform features:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev
|
||||||
|
↓
|
||||||
|
feature branch
|
||||||
|
↓
|
||||||
|
PR back to platform-dev
|
||||||
|
↓
|
||||||
|
platform-dev → dev integration PR
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Platform feature branches MUST target `platform-dev`.
|
||||||
|
- Do NOT target `dev` directly unless the user explicitly asks.
|
||||||
|
- Do NOT use `website-dev` for platform features.
|
||||||
|
- `platform-dev` is the default PR base for TenantPilot platform/application work.
|
||||||
|
- `dev` is the shared integration branch.
|
||||||
|
|
||||||
|
### Solo Workflow Rule
|
||||||
|
|
||||||
|
The user works alone on `platform-dev`.
|
||||||
|
|
||||||
|
For refreshing the integration branch before opening or updating the PR `platform-dev` → `dev`, prefer rebase over merge.
|
||||||
|
|
||||||
|
Do not repeatedly merge `origin/dev` into `platform-dev` for refresh.
|
||||||
|
|
||||||
|
Avoid creating repeated merge commits like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Merge remote-tracking branch 'origin/dev' into platform-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--force-with-lease`, never plain `--force`.
|
||||||
|
|
||||||
|
If rebase conflicts occur, stop and report the conflict files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
1. Confirm repository root.
|
||||||
|
2. Confirm current branch is not protected.
|
||||||
|
|
||||||
|
Protected branches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dev
|
||||||
|
platform-dev
|
||||||
|
website-dev
|
||||||
|
main
|
||||||
|
master
|
||||||
|
```
|
||||||
|
|
||||||
|
If the current branch is protected, STOP and report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ich bin auf einem geschützten Branch. Bitte zuerst einen Feature-Branch auschecken.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Confirm remote exists.
|
||||||
|
4. Confirm there are local changes, untracked files, or unpushed commits.
|
||||||
|
5. Confirm there are no unresolved conflicts.
|
||||||
|
|
||||||
|
Do not ask for confirmation unless:
|
||||||
|
|
||||||
|
- The current branch is protected.
|
||||||
|
- Git status indicates unresolved conflicts.
|
||||||
|
- There is no remote configured.
|
||||||
|
- `.env` or other local secret/config files would be committed.
|
||||||
|
- Commit fails.
|
||||||
|
- Push fails.
|
||||||
|
- Gitea MCP PR creation fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Tools
|
||||||
|
|
||||||
|
Use terminal for Git operations.
|
||||||
|
|
||||||
|
Use Gitea MCP for pull request creation.
|
||||||
|
|
||||||
|
Preferred Gitea MCP operation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
create_pull_request
|
||||||
|
```
|
||||||
|
|
||||||
|
Required PR parameters:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "ahmido",
|
||||||
|
"repo": "TenantAtlas",
|
||||||
|
"head": "<current-feature-branch>",
|
||||||
|
"base": "platform-dev",
|
||||||
|
"title": "<generated-title>",
|
||||||
|
"body": "<generated-body>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1 — Inspect Git state
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
git status --porcelain
|
||||||
|
git status -sb
|
||||||
|
git config --get remote.origin.url
|
||||||
|
git log --oneline --max-count=5
|
||||||
|
```
|
||||||
|
|
||||||
|
Determine:
|
||||||
|
|
||||||
|
- repository root
|
||||||
|
- current branch
|
||||||
|
- changed files
|
||||||
|
- untracked files
|
||||||
|
- remote URL
|
||||||
|
- whether there are unpushed commits
|
||||||
|
- whether unresolved conflicts exist
|
||||||
|
|
||||||
|
If the current branch is protected, stop.
|
||||||
|
|
||||||
|
If unresolved conflicts exist, stop.
|
||||||
|
|
||||||
|
If no remote exists, stop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Check for local environment files
|
||||||
|
|
||||||
|
Before `git add -A`, check whether local environment/config files are modified or untracked:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
If `.env` or another environment file is included, STOP and report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Achtung: Eine .env-/Environment-Datei ist geändert oder untracked. Ich committe das nicht automatisch. Bitte prüfen oder aus dem Commit entfernen.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit secrets or local runtime configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Build commit message
|
||||||
|
|
||||||
|
Use the current branch name.
|
||||||
|
|
||||||
|
If branch starts with a spec number, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
256-external-support-desk-handoff
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(specs/256): external support desk handoff
|
||||||
|
```
|
||||||
|
|
||||||
|
If branch does not contain a spec number, generate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(platform): complete <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Use lowercase subject.
|
||||||
|
- Use feature-style subject.
|
||||||
|
- Do not include `WIP`.
|
||||||
|
- Do not include `final`.
|
||||||
|
- Do not include overly generic `updates`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(specs/256): external support desk handoff
|
||||||
|
feat(specs/252): platform localization v1
|
||||||
|
feat(platform): improve tenant review workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Commit all changes
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "<commit-message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are no local changes to commit, continue only if the branch has unpushed commits.
|
||||||
|
|
||||||
|
Check unpushed commits with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status -sb
|
||||||
|
git log --oneline origin/<current-branch>..HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are no local changes and no unpushed commits, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Es gibt keine lokalen Änderungen und keine unpushed commits. Ich erstelle keinen leeren Commit.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then continue to PR creation only if the branch already exists remotely or can be pushed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Push branch
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --set-upstream origin <current-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the upstream already exists, this is acceptable.
|
||||||
|
|
||||||
|
Never force-push unless the user explicitly requests it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6 — Create PR into platform-dev via Gitea MCP
|
||||||
|
|
||||||
|
Use Gitea MCP to create a pull request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "ahmido",
|
||||||
|
"repo": "TenantAtlas",
|
||||||
|
"head": "<current-feature-branch>",
|
||||||
|
"base": "platform-dev",
|
||||||
|
"title": "<commit-message>",
|
||||||
|
"body": "Implements platform feature branch `<current-feature-branch>`.\n\nTarget branch: `platform-dev`.\n\nFollow-up integration path after merge:\n\n`platform-dev` → `dev`."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a PR already exists for the same branch and base, do not create a duplicate.
|
||||||
|
|
||||||
|
Report the existing PR if available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional Step — Check platform-dev to dev PR
|
||||||
|
|
||||||
|
After creating the feature PR, check whether an open integration PR exists:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev → dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If a Gitea MCP list/search pull request function is available, use it.
|
||||||
|
|
||||||
|
If one exists, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Der Folge-PR `platform-dev` → `dev` existiert bereits: <url>
|
||||||
|
```
|
||||||
|
|
||||||
|
If none exists, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nach dem Merge dieses Feature-PRs sollte der Integrations-PR `platform-dev` → `dev` erstellt oder aktualisiert werden.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not automatically create the `platform-dev` → `dev` PR unless the user explicitly asks for it.
|
||||||
|
|
||||||
|
Reason: before the feature PR is merged into `platform-dev`, the integration PR may not include the new feature yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Refresh Mode
|
||||||
|
|
||||||
|
Use this mode when the user explicitly says one of the following:
|
||||||
|
|
||||||
|
- "platform-dev nach dev vorbereiten"
|
||||||
|
- "platform-dev PR aktualisieren"
|
||||||
|
- "out-of-date mit dev beheben"
|
||||||
|
- "integration PR refresh"
|
||||||
|
- "platform-dev auf dev rebasen"
|
||||||
|
- "auch platform-dev nach dev"
|
||||||
|
- "und danach platform-dev nach dev"
|
||||||
|
- "full integration"
|
||||||
|
- "kompletten platform-dev zu dev PR machen"
|
||||||
|
- "folge-pr erstellen"
|
||||||
|
|
||||||
|
This mode prepares or updates the integration PR:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev → dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the user works alone on `platform-dev`, prefer rebase over merge.
|
||||||
|
|
||||||
|
### Integration Refresh Preconditions
|
||||||
|
|
||||||
|
Before running this mode:
|
||||||
|
|
||||||
|
1. Ensure the working tree is clean.
|
||||||
|
2. Ensure there are no unresolved conflicts.
|
||||||
|
3. Fetch remote branches.
|
||||||
|
4. Ensure `origin/platform-dev` exists.
|
||||||
|
5. Ensure `origin/dev` exists.
|
||||||
|
|
||||||
|
If the working tree is dirty, STOP and report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Der Working Tree ist nicht sauber. Bitte erst Änderungen committen, stashen oder verwerfen, bevor `platform-dev` auf `dev` rebased wird.
|
||||||
|
```
|
||||||
|
|
||||||
|
If unresolved conflicts exist, STOP and report the conflict files.
|
||||||
|
|
||||||
|
### Integration Refresh Workflow
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout platform-dev
|
||||||
|
git reset --hard origin/platform-dev
|
||||||
|
git rebase origin/dev
|
||||||
|
git push --force-with-lease origin platform-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
After pushing, verify that `origin/dev` is now an ancestor of `origin/platform-dev`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git merge-base --is-ancestor origin/dev origin/platform-dev \
|
||||||
|
&& echo "OK: platform-dev contains dev" \
|
||||||
|
|| echo "OUTDATED: platform-dev does not contain dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the verification prints `OUTDATED`, stop and report it. Do not claim the PR is up-to-date.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not merge `origin/dev` into `platform-dev` for this refresh.
|
||||||
|
- Do not create repeated merge commits from `origin/dev` into `platform-dev`.
|
||||||
|
- Use `git push --force-with-lease origin platform-dev` after a successful rebase.
|
||||||
|
- Never use plain `git push --force`.
|
||||||
|
- If `git rebase origin/dev` reports conflicts, stop immediately.
|
||||||
|
- Do not continue to PR creation while a rebase is unresolved.
|
||||||
|
- Do not auto-merge the PR.
|
||||||
|
- Do not claim Gitea will remove the out-of-date warning unless the ancestor check succeeds.
|
||||||
|
|
||||||
|
If rebase conflicts occur, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Rebase-Konflikte erkannt. Ich habe gestoppt.
|
||||||
|
|
||||||
|
Konfliktdateien:
|
||||||
|
<files>
|
||||||
|
|
||||||
|
Bitte Konflikte lösen, dann `git rebase --continue` ausführen oder den Rebase mit `git rebase --abort` abbrechen.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create or Report Integration PR
|
||||||
|
|
||||||
|
After the rebase, push, and ancestor verification succeeded, use Gitea MCP to create or report the integration PR:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "ahmido",
|
||||||
|
"repo": "TenantAtlas",
|
||||||
|
"head": "platform-dev",
|
||||||
|
"base": "dev",
|
||||||
|
"title": "chore(platform): merge platform-dev into dev",
|
||||||
|
"body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.\n\nThis PR was created by agent on user request; do not merge automatically."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If an open PR already exists for `platform-dev` → `dev`, do not create a duplicate. Report the existing PR.
|
||||||
|
|
||||||
|
### Integration Refresh Reporting Format
|
||||||
|
|
||||||
|
Final response for this mode must include:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fertig.
|
||||||
|
|
||||||
|
- Branch aktualisiert: platform-dev
|
||||||
|
- Refresh-Methode: rebase auf origin/dev
|
||||||
|
- Ancestor-Check: origin/dev ist Ancestor von origin/platform-dev
|
||||||
|
- Push: --force-with-lease origin/platform-dev
|
||||||
|
- Integration PR: <url>
|
||||||
|
- Base: dev
|
||||||
|
- Hinweis: PR wurde nicht automatisch gemerged.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not claim tests passed unless they were actually executed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting Format
|
||||||
|
|
||||||
|
Final response must be concise and include:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fertig.
|
||||||
|
|
||||||
|
- Branch: <branch>
|
||||||
|
- Commit: <commit-sha or "keine neuen Änderungen">
|
||||||
|
- Push: origin/<branch>
|
||||||
|
- PR: <url>
|
||||||
|
- Base: platform-dev
|
||||||
|
- Nächster Schritt: Nach Merge `platform-dev` → `dev` PR aktualisieren/erstellen
|
||||||
|
```
|
||||||
|
|
||||||
|
If tests were not run, say:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tests wurden in diesem Skill nicht automatisch ausgeführt.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not claim tests passed unless the tool actually ran them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Never commit directly to `dev`, `platform-dev`, `website-dev`, `main`, or `master`.
|
||||||
|
- Never force-push unless explicitly requested.
|
||||||
|
- For Integration Refresh Mode only, `git push --force-with-lease origin platform-dev` is allowed because the user works alone on `platform-dev`; never use plain `--force`.
|
||||||
|
- Never auto-merge PRs unless explicitly requested.
|
||||||
|
- Never target `dev` directly for platform feature PRs unless explicitly requested.
|
||||||
|
- Never delete branches unless explicitly requested.
|
||||||
|
- Never claim tests were run unless the tool actually ran them.
|
||||||
|
- Never commit `.env`, secrets, local tokens, local mock-server configuration, or temporary runtime-only changes.
|
||||||
|
- If migrations were created, mention that the target environment needs migration execution after deployment.
|
||||||
|
- If unresolved conflicts exist, stop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
git status --porcelain
|
||||||
|
git status -sb
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
Detect protected branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
case "$branch" in
|
||||||
|
dev|platform-dev|website-dev|main|master)
|
||||||
|
echo "PROTECTED_BRANCH:$branch"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
```
|
||||||
|
|
||||||
|
Detect unresolved conflicts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only --diff-filter=U
|
||||||
|
```
|
||||||
|
|
||||||
|
Detect `.env` changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "<message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Latest commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --short HEAD
|
||||||
|
git log -1 --pretty=%s
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration refresh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout platform-dev
|
||||||
|
git reset --hard origin/platform-dev
|
||||||
|
git rebase origin/dev
|
||||||
|
git push --force-with-lease origin platform-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify integration refresh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git merge-base --is-ancestor origin/dev origin/platform-dev \
|
||||||
|
&& echo "OK: platform-dev contains dev" \
|
||||||
|
|| echo "OUTDATED: platform-dev does not contain dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check rebase conflicts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only --diff-filter=U
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example User Request
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
alles committen pushen und pr gegen platform-dev mit gitea mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Assistant should:
|
||||||
|
|
||||||
|
1. Check current branch.
|
||||||
|
2. Stop if branch is protected.
|
||||||
|
3. Stop if `.env` or secrets would be committed.
|
||||||
|
4. Commit all changes.
|
||||||
|
5. Push current branch.
|
||||||
|
6. Create PR into `platform-dev` with Gitea MCP.
|
||||||
|
7. Report result.
|
||||||
|
|
||||||
|
Do not ask unnecessary follow-up questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Integration Refresh Request
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev PR aktualisieren
|
||||||
|
```
|
||||||
|
|
||||||
|
Assistant should:
|
||||||
|
|
||||||
|
1. Ensure the working tree is clean.
|
||||||
|
2. Fetch origin.
|
||||||
|
3. Checkout `platform-dev`.
|
||||||
|
4. Reset local `platform-dev` to `origin/platform-dev`.
|
||||||
|
5. Rebase `platform-dev` onto `origin/dev`.
|
||||||
|
6. Push with `--force-with-lease`.
|
||||||
|
7. Verify `origin/dev` is an ancestor of `origin/platform-dev`.
|
||||||
|
8. Create or report the PR `platform-dev` → `dev`.
|
||||||
|
9. Report result.
|
||||||
|
|
||||||
|
Do not merge the PR automatically.
|
||||||
447
.codex/skills/spec-kit-implementation-loop/SKILL.md
Normal file
447
.codex/skills/spec-kit-implementation-loop/SKILL.md
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
---
|
||||||
|
name: spec-kit-implementation-loop
|
||||||
|
description: Implement an existing TenantPilot/TenantAtlas Spec Kit feature, run tests, browser smoke checks where applicable, post-implementation analysis, fix all confirmed in-scope findings when safe and bounded, and repeat until no in-scope findings remain or a stop condition is reached.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Spec Kit Implementation Loop
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use this skill to implement an already prepared TenantPilot/TenantAtlas Spec Kit feature and verify it with a bounded implementation loop.
|
||||||
|
|
||||||
|
This skill assumes `spec.md`, `plan.md`, and `tasks.md` already exist and have passed preparation readiness or have been explicitly accepted by the user.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active or explicitly named spec
|
||||||
|
→ inspect repo truth, constitution, spec, plan, tasks, and relevant code/tests
|
||||||
|
→ evaluate implementation gates
|
||||||
|
→ implement strictly task-by-task
|
||||||
|
→ run relevant tests/checks
|
||||||
|
→ run browser smoke test when UI/user-facing flows are affected
|
||||||
|
→ run strict post-implementation analysis
|
||||||
|
→ fix confirmed in-scope findings
|
||||||
|
→ repeat test + browser smoke + analysis + fix loop until clean or bounded stop condition is reached
|
||||||
|
→ final implementation report
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this skill when the user asks to:
|
||||||
|
|
||||||
|
- implement an active or explicitly named Spec Kit feature
|
||||||
|
- run Spec Kit implement
|
||||||
|
- analyze after implementation
|
||||||
|
- fix implementation findings
|
||||||
|
- repeat implementation verification until no confirmed in-scope findings remain
|
||||||
|
- run tests and browser smoke checks after implementation
|
||||||
|
|
||||||
|
Typical user prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Implementiere die aktive Spec und analysiere danach, ob alles passt.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Implementiere specs/243-product-usage-adoption-telemetry streng nach tasks.md.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Implementiere die vorbereitete Spec. Danach Tests, Browser Smoke Test falls UI betroffen ist, Analyse und Fix-Loop bis keine In-Scope Findings mehr offen sind.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
- Work strictly repo-based.
|
||||||
|
- Implement only the active or explicitly named Spec Kit feature.
|
||||||
|
- Do not choose a new candidate.
|
||||||
|
- Do not create a new spec.
|
||||||
|
- Do not expand scope beyond `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
|
||||||
|
- Follow the repository constitution and existing Spec Kit conventions.
|
||||||
|
- Preserve TenantPilot/TenantAtlas terminology.
|
||||||
|
- Prefer small, reviewable patches over broad rewrites.
|
||||||
|
- Treat repository truth as authoritative over assumptions.
|
||||||
|
- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope.
|
||||||
|
- Fix only confirmed findings from tests, static checks, browser smoke checks, or post-implementation analysis.
|
||||||
|
- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded.
|
||||||
|
- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why.
|
||||||
|
- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence.
|
||||||
|
- Do not run destructive commands.
|
||||||
|
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets.
|
||||||
|
- Do not continue analysis/fix loops indefinitely.
|
||||||
|
- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated.
|
||||||
|
- Do not claim merge-readiness unless the Merge Readiness Gate passes.
|
||||||
|
|
||||||
|
## Required Inputs
|
||||||
|
|
||||||
|
The user should provide at least one of:
|
||||||
|
|
||||||
|
- explicit spec directory such as `specs/<number>-<slug>/`
|
||||||
|
- instruction to use the current active Spec Kit feature
|
||||||
|
- instruction to implement the prepared/current spec
|
||||||
|
|
||||||
|
If the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory.
|
||||||
|
|
||||||
|
## Required Repository Checks
|
||||||
|
|
||||||
|
Always check:
|
||||||
|
|
||||||
|
1. active Spec Kit context / current branch
|
||||||
|
2. git status
|
||||||
|
3. `.specify/memory/constitution.md`
|
||||||
|
4. the active spec directory
|
||||||
|
5. `spec.md`
|
||||||
|
6. `plan.md`
|
||||||
|
7. `tasks.md`
|
||||||
|
8. relevant templates or conventions under `.specify/templates/`
|
||||||
|
9. nearby existing specs with related terminology or scope
|
||||||
|
10. application code surfaces referenced by the active spec
|
||||||
|
11. existing tests related to the changed behavior
|
||||||
|
|
||||||
|
## Git and Branch Safety
|
||||||
|
|
||||||
|
Before making implementation changes:
|
||||||
|
|
||||||
|
1. Check the current branch.
|
||||||
|
2. Check whether the working tree is clean.
|
||||||
|
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||||
|
4. If the working tree only contains user-intended changes for this operation, continue cautiously.
|
||||||
|
5. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
6. Do not overwrite unrelated work.
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
### Gate 1: Spec Readiness Gate
|
||||||
|
|
||||||
|
Required before implementation starts.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- `spec.md`, `plan.md`, and `tasks.md` exist.
|
||||||
|
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
|
||||||
|
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
|
||||||
|
- The tasks are small, ordered, verifiable, and include test/validation tasks.
|
||||||
|
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
|
||||||
|
- No open question blocks safe implementation.
|
||||||
|
- The scope is small enough for a bounded implementation loop.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Stop before implementation.
|
||||||
|
- Report readiness gaps.
|
||||||
|
- Do not compensate for an unclear spec by inventing implementation scope.
|
||||||
|
|
||||||
|
### Gate 2: Implementation Scope Gate
|
||||||
|
|
||||||
|
Required before changing application code.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- The active spec directory is known.
|
||||||
|
- The implementation target is traceable to specific tasks in `tasks.md`.
|
||||||
|
- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth.
|
||||||
|
- No required change would introduce unrelated product behavior.
|
||||||
|
- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Stop before code changes and report the conflict or ambiguity.
|
||||||
|
- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase.
|
||||||
|
|
||||||
|
### Gate 3: Test Gate
|
||||||
|
|
||||||
|
Required after implementation and after each fix iteration.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- Targeted tests for changed behavior pass.
|
||||||
|
- Relevant existing tests pass or failures are proven unrelated and documented.
|
||||||
|
- Static analysis, linting, formatting, or type checks used by the repository pass when applicable.
|
||||||
|
- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough.
|
||||||
|
- Regression coverage exists for each fixed Blocker or High finding where practical.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix in-scope failures before post-implementation analysis.
|
||||||
|
- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec.
|
||||||
|
- Do not weaken tests to pass the gate.
|
||||||
|
|
||||||
|
### Gate 4: Browser Smoke Test Gate
|
||||||
|
|
||||||
|
Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
|
||||||
|
|
||||||
|
Not required for backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- The relevant page or flow loads in a real browser or the repository's browser-testing harness.
|
||||||
|
- The primary action introduced or changed by the spec can be executed successfully.
|
||||||
|
- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant.
|
||||||
|
- Workspace/tenant context is preserved across the tested flow where relevant.
|
||||||
|
- RBAC/capability-dependent visibility behaves as expected where practical to verify.
|
||||||
|
- Livewire interactions complete without visible runtime errors.
|
||||||
|
- No relevant browser console errors occur.
|
||||||
|
- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented.
|
||||||
|
- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant.
|
||||||
|
- The smoke-tested path is documented in the final response.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness.
|
||||||
|
- If a browser issue is unrelated existing debt, document evidence and residual risk.
|
||||||
|
- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests.
|
||||||
|
- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that.
|
||||||
|
|
||||||
|
### Gate 5: Post-Implementation Analysis Gate
|
||||||
|
|
||||||
|
Required after implementation and after each fix iteration.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution.
|
||||||
|
- All completed tasks have implementation evidence.
|
||||||
|
- No confirmed in-scope findings remain.
|
||||||
|
- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe.
|
||||||
|
- Medium/Low findings that remain open are explicitly documented with one of these reasons:
|
||||||
|
- out of scope
|
||||||
|
- requires separate spec
|
||||||
|
- risky refactor
|
||||||
|
- existing unrelated debt
|
||||||
|
- not reproducible
|
||||||
|
- blocked by unclear product/architecture decision
|
||||||
|
- No scope expansion was introduced during fixes.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded.
|
||||||
|
- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice.
|
||||||
|
|
||||||
|
### Gate 6: Merge Readiness Gate
|
||||||
|
|
||||||
|
Required before claiming the implementation is ready for manual review/merge.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- Spec Readiness Gate passed.
|
||||||
|
- Implementation Scope Gate passed.
|
||||||
|
- Test Gate passed.
|
||||||
|
- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason.
|
||||||
|
- Post-Implementation Analysis Gate passed.
|
||||||
|
- `tasks.md` reflects actual completion status.
|
||||||
|
- No confirmed in-scope findings remain.
|
||||||
|
- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks.
|
||||||
|
- Final response includes changed files, tests/checks run, browser smoke result, iterations performed, residual risks, and follow-up candidates.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Do not claim merge-readiness.
|
||||||
|
- Report the failed gate, remaining risks, and the smallest recommended next action.
|
||||||
|
|
||||||
|
## Implementation Loop
|
||||||
|
|
||||||
|
Execute the loop in bounded phases:
|
||||||
|
|
||||||
|
1. Evaluate the Spec Readiness Gate.
|
||||||
|
2. Evaluate the Implementation Scope Gate before changing application code.
|
||||||
|
3. Implement the active Spec Kit feature scope task-by-task.
|
||||||
|
4. Run targeted tests and relevant static/dynamic checks.
|
||||||
|
5. Evaluate the Test Gate.
|
||||||
|
6. Run a Browser Smoke Test when the change affects UI/user-facing flows.
|
||||||
|
7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason.
|
||||||
|
8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns.
|
||||||
|
9. Evaluate the Post-Implementation Analysis Gate.
|
||||||
|
10. Identify confirmed findings by severity: Blocker, High, Medium, Low.
|
||||||
|
11. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
|
||||||
|
12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons.
|
||||||
|
13. Re-run relevant tests and browser smoke checks where applicable after fixes.
|
||||||
|
14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached.
|
||||||
|
15. Evaluate the Merge Readiness Gate.
|
||||||
|
16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
Stop the implementation loop when any of the following is true:
|
||||||
|
|
||||||
|
- No confirmed in-scope findings remain.
|
||||||
|
- The same finding appears twice after attempted fixes.
|
||||||
|
- A required fix conflicts with the spec, plan, constitution, or repository architecture.
|
||||||
|
- A required fix would expand scope beyond the active spec.
|
||||||
|
- A required fix would require a risky unrelated refactor.
|
||||||
|
- A required fix depends on an unresolved product or architecture decision.
|
||||||
|
- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec.
|
||||||
|
- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec.
|
||||||
|
- Three analysis/fix iterations have already been completed.
|
||||||
|
- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics.
|
||||||
|
|
||||||
|
When stopping before full cleanliness, report exactly why the loop stopped and what remains.
|
||||||
|
|
||||||
|
## Post-Implementation Analysis Prompt
|
||||||
|
|
||||||
|
Use this prompt internally after implementation and after each fix iteration:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer.
|
||||||
|
|
||||||
|
Analysiere die Implementierung der aktiven Spec streng repo-basiert.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist.
|
||||||
|
|
||||||
|
Prüfe gegen:
|
||||||
|
- spec.md
|
||||||
|
- plan.md
|
||||||
|
- tasks.md
|
||||||
|
- .specify/memory/constitution.md
|
||||||
|
- geänderte Anwendungscodes
|
||||||
|
- geänderte Tests
|
||||||
|
- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind
|
||||||
|
- bestehende Repository-Patterns
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Spekulation ohne Repo-Beleg.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Keine neuen Produktideen als Pflicht-Fixes.
|
||||||
|
- Findings nach Blocker, High, Medium, Low gruppieren.
|
||||||
|
- Für jedes Finding konkrete Datei-/Code-Belege nennen.
|
||||||
|
- Für jedes Finding eine minimale Remediation nennen.
|
||||||
|
- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen.
|
||||||
|
- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind.
|
||||||
|
- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert.
|
||||||
|
- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind.
|
||||||
|
- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Completion Rules
|
||||||
|
|
||||||
|
- Keep `tasks.md` aligned with actual implementation status.
|
||||||
|
- Check off tasks only after the implementation and test evidence exists.
|
||||||
|
- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it.
|
||||||
|
- If a task cannot be completed inside scope, leave it unchecked and report why.
|
||||||
|
|
||||||
|
## Testing Rules
|
||||||
|
|
||||||
|
- Add or update tests for all changed business behavior.
|
||||||
|
- Include RBAC and workspace/tenant isolation tests where relevant.
|
||||||
|
- Include OperationRun, audit, evidence, or result-truth tests where relevant.
|
||||||
|
- Prefer regression tests for every fixed Blocker or High finding.
|
||||||
|
- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn.
|
||||||
|
- Do not weaken tests to pass the suite.
|
||||||
|
- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant.
|
||||||
|
|
||||||
|
## Browser Smoke Test Rules
|
||||||
|
|
||||||
|
Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
|
||||||
|
|
||||||
|
The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested.
|
||||||
|
|
||||||
|
Minimum smoke path:
|
||||||
|
|
||||||
|
1. Open the relevant page or entry point.
|
||||||
|
2. Confirm the expected workspace/tenant context where relevant.
|
||||||
|
3. Confirm the changed or newly introduced UI element is visible.
|
||||||
|
4. Execute the primary action or interaction changed by the spec.
|
||||||
|
5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown.
|
||||||
|
6. Check for relevant console errors.
|
||||||
|
7. Check for failed network requests related to the tested flow.
|
||||||
|
8. Document the tested path in the final response.
|
||||||
|
|
||||||
|
For TenantPilot/TenantAtlas, pay special attention to:
|
||||||
|
|
||||||
|
- Filament actions and header actions
|
||||||
|
- Livewire polling, modals, validation, and actions
|
||||||
|
- workspace/tenant context preservation
|
||||||
|
- RBAC/capability-dependent action visibility
|
||||||
|
- OperationRun links and drilldown continuity
|
||||||
|
- audit/evidence/result/support-diagnostic drilldowns where relevant
|
||||||
|
- empty states, badges, labels, and decision guidance where relevant
|
||||||
|
|
||||||
|
Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes.
|
||||||
|
|
||||||
|
Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
If an implementation step, test phase, browser smoke phase, or post-implementation analysis fails:
|
||||||
|
|
||||||
|
1. Stop at the relevant gate or stop condition.
|
||||||
|
2. Report the failing command or phase.
|
||||||
|
3. Summarize the error.
|
||||||
|
4. Do not attempt unrelated implementation as a workaround.
|
||||||
|
5. Suggest the smallest safe next action.
|
||||||
|
|
||||||
|
If the branch or working tree state is unsafe:
|
||||||
|
|
||||||
|
1. Stop before implementation changes.
|
||||||
|
2. Report the current branch and relevant uncommitted files.
|
||||||
|
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||||
|
|
||||||
|
## Final Response Requirements
|
||||||
|
|
||||||
|
Respond with:
|
||||||
|
|
||||||
|
1. Active spec directory
|
||||||
|
2. Summary of implemented changes
|
||||||
|
3. Tests/checks run and their results
|
||||||
|
4. Browser smoke test result, tested path, or not-applicable reason
|
||||||
|
5. Quality gates passed/failed and number of analysis/fix iterations performed
|
||||||
|
6. Remaining in-scope findings, if any
|
||||||
|
7. Residual risks and follow-up candidates, if relevant
|
||||||
|
8. Files changed
|
||||||
|
9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge
|
||||||
|
|
||||||
|
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||||
|
|
||||||
|
## Manual Review Prompt
|
||||||
|
|
||||||
|
Provide a ready-to-copy prompt like this, adapted to the active spec number and slug:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||||
|
|
||||||
|
Führe eine finale manuelle Review der implementierten Spec `<spec-number>-<slug>` streng repo-basiert durch.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Implementierung.
|
||||||
|
- Keine Codeänderungen.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md.
|
||||||
|
- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant.
|
||||||
|
- Benenne nur konkrete Findings mit Repo-Beleg.
|
||||||
|
- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Invocation
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nutze den Skill spec-kit-implementation-loop.
|
||||||
|
Implementiere die aktive Spec.
|
||||||
|
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
|
||||||
|
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests.
|
||||||
|
2. Evaluate the Spec Readiness Gate and Implementation Scope Gate.
|
||||||
|
3. Implement only the active spec scope.
|
||||||
|
4. Run targeted tests and relevant checks.
|
||||||
|
5. Evaluate the Test Gate.
|
||||||
|
6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected.
|
||||||
|
7. Run post-implementation analysis.
|
||||||
|
8. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
|
||||||
|
9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions.
|
||||||
|
10. Evaluate the Merge Readiness Gate.
|
||||||
|
11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt.
|
||||||
|
```
|
||||||
612
.codex/skills/spec-kit-next-best-prep/SKILL.md
Normal file
612
.codex/skills/spec-kit-next-best-prep/SKILL.md
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
---
|
||||||
|
name: spec-kit-next-best-prep
|
||||||
|
description: Select the next suitable TenantPilot/TenantAtlas spec candidate from roadmap/spec-candidates, run the repository's Spec Kit preparation flow, create or update spec.md/plan.md/tasks.md, run preparation analysis, fix preparation-artifact issues only, and stop before application implementation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Spec Kit Next-Best Preparation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use this skill to prepare the next implementation-ready Spec Kit package for TenantPilot/TenantAtlas without implementing application code.
|
||||||
|
|
||||||
|
This skill supports preparation only:
|
||||||
|
|
||||||
|
1. Select or scope the next suitable feature from roadmap/spec-candidates.
|
||||||
|
2. Run the repository's real Spec Kit preparation workflow where available.
|
||||||
|
3. Create or update `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
4. Run preparation `analyze` when supported.
|
||||||
|
5. Fix preparation-artifact issues only.
|
||||||
|
6. Evaluate preparation quality gates.
|
||||||
|
7. Stop before application implementation.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
roadmap / spec-candidates / feature idea
|
||||||
|
→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code
|
||||||
|
→ select the next suitable candidate or scope the provided idea
|
||||||
|
→ run Spec Kit specify/plan/tasks/analyze where available
|
||||||
|
→ create or update spec.md + plan.md + tasks.md
|
||||||
|
→ fix preparation-artifact issues only
|
||||||
|
→ evaluate Candidate Selection Gate and Spec Readiness Gate
|
||||||
|
→ final preparation report
|
||||||
|
→ explicit implementation step later
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this skill when the user asks to:
|
||||||
|
|
||||||
|
- select the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources
|
||||||
|
- turn a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md`
|
||||||
|
- prepare Spec Kit artifacts in one pass
|
||||||
|
- run specify/plan/tasks/analyze without implementation
|
||||||
|
- fix preparation analysis issues in Spec Kit artifacts only
|
||||||
|
- prepare a feature package for a later implementation skill
|
||||||
|
|
||||||
|
Typical user prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und führe specify, plan, tasks und analyze aus.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Behebe alle analyze-Issues in den Spec-Kit-Artefakten. Keine Application-Implementierung.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
- Work strictly repo-based.
|
||||||
|
- This is a preparation-only skill.
|
||||||
|
- Do not implement application code.
|
||||||
|
- Do not modify production code.
|
||||||
|
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, routes, views, tests, or runtime behavior.
|
||||||
|
- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available.
|
||||||
|
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
|
||||||
|
- Do not bypass Spec Kit branch mechanics.
|
||||||
|
- Create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts.
|
||||||
|
- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
|
||||||
|
- Follow the repository constitution and existing Spec Kit conventions.
|
||||||
|
- Preserve TenantPilot/TenantAtlas terminology.
|
||||||
|
- Prefer small, reviewable, implementation-ready specs over broad rewrites.
|
||||||
|
- Treat repository truth as authoritative over assumptions.
|
||||||
|
- If repository truth conflicts with the user-provided draft or candidate wording, keep repository truth and document the deviation.
|
||||||
|
- Fix only confirmed preparation-artifact findings from Spec Kit preparation analysis.
|
||||||
|
- Do not leave preparation findings open silently. If they are not fixed, document exactly why.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
The user should provide at least one of:
|
||||||
|
|
||||||
|
- feature title and short goal
|
||||||
|
- full spec candidate
|
||||||
|
- roadmap item
|
||||||
|
- rough problem statement
|
||||||
|
- UX or architecture improvement idea
|
||||||
|
- instruction to choose the next best candidate from roadmap/spec-candidates
|
||||||
|
|
||||||
|
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions.
|
||||||
|
|
||||||
|
If no suitable candidate can be selected safely, stop and report why.
|
||||||
|
|
||||||
|
## Required Repository Checks
|
||||||
|
|
||||||
|
Always check:
|
||||||
|
|
||||||
|
1. `.specify/memory/constitution.md`
|
||||||
|
2. `.specify/templates/`
|
||||||
|
3. `.specify/scripts/`
|
||||||
|
4. existing Spec Kit command usage or repository instructions, if present
|
||||||
|
5. current branch and git status
|
||||||
|
6. `specs/`
|
||||||
|
7. `docs/product/spec-candidates.md`
|
||||||
|
8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
||||||
|
9. nearby existing specs with related terminology or scope
|
||||||
|
10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
1. Check the current branch.
|
||||||
|
2. Check whether the working tree is clean.
|
||||||
|
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||||
|
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
|
||||||
|
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
|
||||||
|
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
7. Do not overwrite existing specs.
|
||||||
|
|
||||||
|
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
### Gate 1: Candidate Selection Gate
|
||||||
|
|
||||||
|
Required before creating a new spec from roadmap/spec-candidates.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Required before reporting that the package is ready for implementation.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- `spec.md`, `plan.md`, and `tasks.md` exist.
|
||||||
|
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
|
||||||
|
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
|
||||||
|
- The tasks are small, ordered, verifiable, and include test/validation tasks.
|
||||||
|
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
|
||||||
|
- No open question blocks safe implementation.
|
||||||
|
- The scope is small enough for a bounded implementation loop in a later implementation skill.
|
||||||
|
- Required checklist artifacts exist when the constitution requires them.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix preparation-artifact issues when they are safe and bounded.
|
||||||
|
- If readiness cannot be achieved without implementation or unresolved product decisions, stop and report the gap.
|
||||||
|
- Do not compensate for an unclear spec by inventing implementation scope.
|
||||||
|
|
||||||
|
## Candidate Selection Rules
|
||||||
|
|
||||||
|
When the user asks for the next best spec from roadmap/spec-candidates:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- If multiple candidates are plausible, choose one primary candidate and document why it was selected.
|
||||||
|
- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope.
|
||||||
|
- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one.
|
||||||
|
- Do not pick a spec only because it is listed first.
|
||||||
|
- Evaluate the Candidate Selection Gate before creating the spec directory.
|
||||||
|
|
||||||
|
Evaluate candidates using these criteria:
|
||||||
|
|
||||||
|
1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer?
|
||||||
|
2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns?
|
||||||
|
3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent?
|
||||||
|
4. **Scope Size**: Can it be implemented as a narrow, testable slice?
|
||||||
|
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
|
||||||
|
|
||||||
|
Before running the Spec Kit flow, identify:
|
||||||
|
|
||||||
|
- selected candidate title
|
||||||
|
- source location in roadmap/spec-candidates
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
|
||||||
|
|
||||||
|
## Spec Kit Preparation Flow
|
||||||
|
|
||||||
|
### Step 1: Determine the repository's Spec Kit command pattern
|
||||||
|
|
||||||
|
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
|
||||||
|
|
||||||
|
Common locations to inspect:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.specify/scripts/
|
||||||
|
.specify/templates/
|
||||||
|
.specify/memory/constitution.md
|
||||||
|
.github/prompts/
|
||||||
|
.github/skills/
|
||||||
|
README.md
|
||||||
|
specs/
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the repo-specific mechanism if present.
|
||||||
|
|
||||||
|
### Step 2: Run `specify`
|
||||||
|
|
||||||
|
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
|
||||||
|
|
||||||
|
The `specify` input should include:
|
||||||
|
|
||||||
|
- selected candidate title
|
||||||
|
- problem statement
|
||||||
|
- operator/user value
|
||||||
|
- roadmap relationship
|
||||||
|
- out-of-scope boundaries
|
||||||
|
- key acceptance criteria
|
||||||
|
- important enterprise constraints
|
||||||
|
|
||||||
|
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
|
||||||
|
|
||||||
|
### Step 3: Run `plan`
|
||||||
|
|
||||||
|
Run the repository's `plan` flow for the generated spec.
|
||||||
|
|
||||||
|
The `plan` input should keep the scope tight and should require repo-based alignment with:
|
||||||
|
|
||||||
|
- constitution
|
||||||
|
- existing architecture
|
||||||
|
- workspace/tenant isolation
|
||||||
|
- RBAC
|
||||||
|
- OperationRun/observability where relevant
|
||||||
|
- evidence/snapshot/truth semantics where relevant
|
||||||
|
- Filament/Livewire conventions where relevant
|
||||||
|
- test strategy
|
||||||
|
|
||||||
|
### Step 4: Run `tasks`
|
||||||
|
|
||||||
|
Run the repository's `tasks` flow for the generated plan.
|
||||||
|
|
||||||
|
The generated tasks must be:
|
||||||
|
|
||||||
|
- ordered
|
||||||
|
- small
|
||||||
|
- testable
|
||||||
|
- grouped by phase
|
||||||
|
- limited to the selected scope
|
||||||
|
- suitable for later implementation or manual analysis before implementation
|
||||||
|
|
||||||
|
### Step 5: Run preparation `analyze`
|
||||||
|
|
||||||
|
Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it.
|
||||||
|
|
||||||
|
Analyze must check:
|
||||||
|
|
||||||
|
- consistency between `spec.md`, `plan.md`, and `tasks.md`
|
||||||
|
- constitution alignment
|
||||||
|
- roadmap alignment
|
||||||
|
- whether the selected candidate was narrowed safely
|
||||||
|
- whether tasks are complete enough for implementation
|
||||||
|
- whether tasks accidentally require scope not described in the spec
|
||||||
|
- whether plan details conflict with repository architecture or terminology
|
||||||
|
- whether implementation risks are documented instead of silently ignored
|
||||||
|
|
||||||
|
Do not use analyze as a trigger to implement application code.
|
||||||
|
|
||||||
|
### Step 6: Fix preparation-artifact issues only
|
||||||
|
|
||||||
|
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`
|
||||||
|
- `tasks.md`
|
||||||
|
- `checklists/requirements.md` or other generated Spec Kit metadata files, if the repository uses them
|
||||||
|
|
||||||
|
Allowed fixes include:
|
||||||
|
|
||||||
|
- clarify requirements
|
||||||
|
- tighten scope
|
||||||
|
- move out-of-scope work into follow-up candidates
|
||||||
|
- correct terminology
|
||||||
|
- add missing tasks
|
||||||
|
- remove tasks not backed by the spec
|
||||||
|
- align plan language with repository architecture
|
||||||
|
- add missing acceptance criteria or validation tasks
|
||||||
|
- add missing checklist artifacts required by the constitution
|
||||||
|
|
||||||
|
Forbidden fixes include:
|
||||||
|
|
||||||
|
- modifying application code
|
||||||
|
- creating migrations
|
||||||
|
- 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
|
||||||
|
|
||||||
|
After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate.
|
||||||
|
|
||||||
|
Stop after this gate and do not implement.
|
||||||
|
|
||||||
|
## Spec Directory Rules
|
||||||
|
|
||||||
|
When creating a new spec directory, use the repository's Spec Kit-generated directory or path.
|
||||||
|
|
||||||
|
If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug:
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/<number>-<slug>/
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact number must be derived from the current repository state and existing numbering conventions.
|
||||||
|
|
||||||
|
Create or update preparation artifacts inside the selected spec directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/<number>-<slug>/spec.md
|
||||||
|
specs/<number>-<slug>/plan.md
|
||||||
|
specs/<number>-<slug>/tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions.
|
||||||
|
|
||||||
|
## `spec.md` Requirements
|
||||||
|
|
||||||
|
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- Feature title
|
||||||
|
- Problem statement
|
||||||
|
- Business/product value
|
||||||
|
- Primary users/operators
|
||||||
|
- User stories
|
||||||
|
- Functional requirements
|
||||||
|
- Non-functional requirements
|
||||||
|
- UX requirements
|
||||||
|
- RBAC/security requirements
|
||||||
|
- Auditability/observability requirements
|
||||||
|
- Data/truth-source requirements where relevant
|
||||||
|
- Out of scope
|
||||||
|
- Acceptance criteria
|
||||||
|
- Success criteria
|
||||||
|
- Risks
|
||||||
|
- Assumptions
|
||||||
|
- Open questions
|
||||||
|
|
||||||
|
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
|
||||||
|
|
||||||
|
- workspace/tenant isolation
|
||||||
|
- capability-first RBAC
|
||||||
|
- auditability
|
||||||
|
- operation/result truth separation
|
||||||
|
- source-of-truth clarity
|
||||||
|
- calm enterprise operator UX
|
||||||
|
- progressive disclosure where useful
|
||||||
|
- no false positive calmness
|
||||||
|
|
||||||
|
## `plan.md` Requirements
|
||||||
|
|
||||||
|
The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- Technical approach
|
||||||
|
- Existing repository surfaces likely affected
|
||||||
|
- Domain/model implications
|
||||||
|
- UI/Filament implications
|
||||||
|
- Livewire implications where relevant
|
||||||
|
- OperationRun/monitoring implications where relevant
|
||||||
|
- RBAC/policy implications
|
||||||
|
- Audit/logging/evidence implications where relevant
|
||||||
|
- Data/migration implications where relevant
|
||||||
|
- Test strategy
|
||||||
|
- Rollout considerations
|
||||||
|
- Risk controls
|
||||||
|
- Implementation phases
|
||||||
|
|
||||||
|
The plan should clearly distinguish where relevant:
|
||||||
|
|
||||||
|
- execution truth
|
||||||
|
- artifact truth
|
||||||
|
- backup/snapshot truth
|
||||||
|
- recovery/evidence truth
|
||||||
|
- operator next action
|
||||||
|
|
||||||
|
## `tasks.md` Requirements
|
||||||
|
|
||||||
|
Tasks must be ordered, small, and verifiable.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- checkbox tasks
|
||||||
|
- phase grouping
|
||||||
|
- tests before or alongside implementation tasks where practical
|
||||||
|
- final validation tasks
|
||||||
|
- documentation/update tasks if needed
|
||||||
|
- explicit non-goals where useful
|
||||||
|
|
||||||
|
Avoid vague tasks such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Clean up code
|
||||||
|
Refactor UI
|
||||||
|
Improve performance
|
||||||
|
Make it enterprise-ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer concrete tasks such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
|
||||||
|
- [ ] Update <specific Filament page/resource> to display <specific state>.
|
||||||
|
- [ ] Add policy coverage for <specific capability>.
|
||||||
|
```
|
||||||
|
|
||||||
|
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
|
||||||
|
|
||||||
|
## Preparation Scope Control
|
||||||
|
|
||||||
|
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
|
||||||
|
|
||||||
|
Examples of follow-up candidates:
|
||||||
|
|
||||||
|
- assigned findings
|
||||||
|
- pending approvals
|
||||||
|
- personal work queue
|
||||||
|
- notification delivery settings
|
||||||
|
- evidence pack export hardening
|
||||||
|
- operation monitoring refinements
|
||||||
|
- autonomous governance decision surfaces
|
||||||
|
|
||||||
|
Do not force all follow-up candidates into the primary spec.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
If a Spec Kit command or preparation analyze phase fails:
|
||||||
|
|
||||||
|
1. Stop at the relevant gate.
|
||||||
|
2. Report the failing command or phase.
|
||||||
|
3. Summarize the error.
|
||||||
|
4. Do not attempt implementation as a workaround.
|
||||||
|
5. Suggest the smallest safe next action.
|
||||||
|
|
||||||
|
If the branch or working tree state is unsafe:
|
||||||
|
|
||||||
|
1. Stop before running Spec Kit commands.
|
||||||
|
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. 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.
|
||||||
|
|
||||||
|
## Manual Review and Next-Step Prompts
|
||||||
|
|
||||||
|
Provide a ready-to-copy manual artifact review prompt like this, adapted to the generated spec branch/path:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||||
|
|
||||||
|
Analysiere die neu erstellte Spec `<spec-branch-or-spec-path>` streng repo-basiert.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Implementierung.
|
||||||
|
- Keine Codeänderungen.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Prüfe nur gegen Repo-Wahrheit.
|
||||||
|
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
|
||||||
|
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
|
||||||
|
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
|
||||||
|
```
|
||||||
|
|
||||||
|
Also provide a ready-to-copy implementation prompt for the separate implementation skill after analyze has passed or preparation-artifact issues have been fixed:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
/spec-kit-implementation-loop
|
||||||
|
|
||||||
|
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
|
||||||
|
|
||||||
|
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
|
||||||
|
|
||||||
|
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Invocation
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nutze den Skill spec-kit-next-best-prep.
|
||||||
|
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
|
||||||
|
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
|
||||||
|
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
|
||||||
|
Keine Application-Implementierung.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
|
||||||
|
2. Check branch and working tree safety.
|
||||||
|
3. Compare candidate suitability.
|
||||||
|
4. Select the next best candidate.
|
||||||
|
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.
|
||||||
|
```
|
||||||
129
.codex/skills/tailwindcss-development/SKILL.md
Normal file
129
.codex/skills/tailwindcss-development/SKILL.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
name: tailwindcss-development
|
||||||
|
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Development
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Adding styles to components or pages
|
||||||
|
- Working with responsive design
|
||||||
|
- Implementing dark mode
|
||||||
|
- Extracting repeated patterns into components
|
||||||
|
- Debugging spacing or layout issues
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||||
|
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||||
|
|
||||||
|
## Tailwind CSS v4 Specifics
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
|
||||||
|
### CSS-First Configuration
|
||||||
|
|
||||||
|
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||||
|
|
||||||
|
<!-- CSS-First Config -->
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Syntax
|
||||||
|
|
||||||
|
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<!-- v4 Import Syntax -->
|
||||||
|
```diff
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
|
||||||
|
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------|-------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Use `gap` utilities instead of margins for spacing between siblings:
|
||||||
|
|
||||||
|
<!-- Gap Utilities -->
|
||||||
|
```html
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Item 1</div>
|
||||||
|
<div>Item 2</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||||
|
|
||||||
|
<!-- Dark Mode -->
|
||||||
|
```html
|
||||||
|
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
|
Content adapts to color scheme
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Flexbox Layout
|
||||||
|
|
||||||
|
<!-- Flexbox Layout -->
|
||||||
|
```html
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>Left content</div>
|
||||||
|
<div>Right content</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Layout
|
||||||
|
|
||||||
|
<!-- Grid Layout -->
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>Card 1</div>
|
||||||
|
<div>Card 2</div>
|
||||||
|
<div>Card 3</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||||
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
@ -4,11 +4,21 @@
|
|||||||
},
|
},
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"laravel-boost": {
|
"laravel-boost": {
|
||||||
"command": "vendor/bin/sail",
|
"command": "/Users/ahmeddarrazi/Documents/projects/wt-plattform/scripts/platform-sail",
|
||||||
"args": [
|
"args": [
|
||||||
"artisan",
|
"artisan",
|
||||||
"boost:mcp"
|
"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)
|
- 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)
|
- 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)
|
- 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)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -300,9 +312,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## 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`
|
- 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
|
||||||
- 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
|
- 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`
|
||||||
- 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
|
- 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 -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### 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 -->
|
<!-- 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 run destructive commands.
|
||||||
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
- Do not overwrite existing specs.
|
- 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.
|
- Do not move from preparation to an implementation step inside this skill.
|
||||||
|
|
||||||
## Required Inputs
|
## Required Inputs
|
||||||
@ -119,6 +122,32 @@ ## Required Repository Checks
|
|||||||
|
|
||||||
Do not edit application code.
|
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
|
## Git and Branch Safety
|
||||||
|
|
||||||
Before running any Spec Kit command:
|
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 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 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 selected candidate aligns with current roadmap priorities or explicitly documented product direction.
|
||||||
- The candidate can be scoped as a small, reviewable, implementation-ready slice.
|
- 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.
|
- 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:
|
Fail behavior:
|
||||||
|
|
||||||
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
|
- 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.
|
- Do not invent a new roadmap direction to force progress.
|
||||||
|
|
||||||
### Gate 2: Spec Readiness Gate
|
### Gate 2: Spec Readiness Gate
|
||||||
@ -180,6 +211,8 @@ ## Candidate Selection Rules
|
|||||||
- Read `docs/product/spec-candidates.md`.
|
- Read `docs/product/spec-candidates.md`.
|
||||||
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
||||||
- Check existing specs to avoid duplicates.
|
- 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 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 candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
||||||
- Prefer small, implementation-ready slices over broad platform rewrites.
|
- 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?
|
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?
|
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?
|
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
|
## Required Selection Output Before Spec Kit Execution
|
||||||
|
|
||||||
@ -208,6 +242,7 @@ ## Required Selection Output Before Spec Kit Execution
|
|||||||
- why it was selected
|
- why it was selected
|
||||||
- why close alternatives were deferred
|
- why close alternatives were deferred
|
||||||
- roadmap relationship
|
- roadmap relationship
|
||||||
|
- completed-spec check result for related existing specs
|
||||||
- smallest viable implementation slice
|
- smallest viable implementation slice
|
||||||
- proposed concise feature description to feed into `specify`
|
- proposed concise feature description to feed into `specify`
|
||||||
|
|
||||||
@ -296,7 +331,7 @@ ### Step 5: Run preparation `analyze`
|
|||||||
|
|
||||||
### Step 6: Fix preparation-artifact issues only
|
### 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`
|
- `spec.md`
|
||||||
- `plan.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
|
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
|
||||||
- running implementation or test-fix loops
|
- running implementation or test-fix loops
|
||||||
- changing runtime behavior
|
- 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
|
### Step 7: Evaluate the Spec Readiness Gate
|
||||||
|
|
||||||
@ -478,23 +517,33 @@ ## Failure Handling
|
|||||||
2. Report the current branch and relevant uncommitted files.
|
2. Report the current branch and relevant uncommitted files.
|
||||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
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
|
## Final Response Requirements
|
||||||
|
|
||||||
Respond with:
|
Respond with:
|
||||||
|
|
||||||
1. Selected candidate and why it was chosen
|
1. Selected candidate and why it was chosen
|
||||||
2. Why close alternatives were deferred
|
2. Why close alternatives were deferred
|
||||||
3. Current branch after Spec Kit execution, if changed
|
3. Completed-spec guardrail result for related existing specs
|
||||||
4. Generated spec path
|
4. Current branch after Spec Kit execution, if changed
|
||||||
5. Files created or updated by Spec Kit
|
5. Generated spec path
|
||||||
6. Preparation analyze result summary
|
6. Files created or updated by Spec Kit
|
||||||
7. Preparation-artifact fixes applied after analyze
|
7. Preparation analyze result summary
|
||||||
8. Assumptions made
|
8. Preparation-artifact fixes applied after analyze
|
||||||
9. Open questions, if any
|
9. Assumptions made
|
||||||
10. Candidate Selection Gate result
|
10. Open questions, if any
|
||||||
11. Spec Readiness Gate result
|
11. Candidate Selection Gate result
|
||||||
12. Recommended next implementation prompt
|
12. Spec Readiness Gate result
|
||||||
13. Explicit statement that no application implementation was performed
|
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.
|
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.
|
2. Check branch and working tree safety.
|
||||||
3. Compare candidate suitability.
|
3. Compare candidate suitability.
|
||||||
4. Select the next best candidate.
|
4. Select the next best candidate.
|
||||||
5. Evaluate the Candidate Selection Gate.
|
5. Exclude already completed specs from preparation or refresh targets, preserving their close-out and validation history.
|
||||||
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
6. Evaluate the Candidate Selection Gate.
|
||||||
7. Run the repository's real Spec Kit `plan` flow.
|
7. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||||
8. Run the repository's real Spec Kit `tasks` flow.
|
8. Run the repository's real Spec Kit `plan` flow.
|
||||||
9. Run the repository's real Spec Kit preparation `analyze` flow.
|
9. Run the repository's real Spec Kit `tasks` flow.
|
||||||
10. Fix analyze issues only in Spec Kit preparation artifacts.
|
10. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||||
11. Evaluate the Spec Readiness Gate.
|
11. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||||
12. Stop before application implementation.
|
12. Evaluate the Spec Readiness Gate.
|
||||||
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
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
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.10.0 -> 2.11.0
|
- Version change: 2.12.0 -> 2.13.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Expanded decision-first and operator-surface rules so operational,
|
- Expanded Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
governance, evidence, onboarding, review, and support-facing
|
so custom Filament UI must follow the canonical TenantPilot
|
||||||
detail/status surfaces separate decision content, operator
|
enterprise UI standard, must not introduce ad-hoc styling for
|
||||||
diagnostics, and support/raw evidence
|
cards, buttons, hovers, badges, icons, progress bars, empty states,
|
||||||
- Expanded review and enforcement expectations so specs, plans,
|
or interactive rows, and may only show interactive affordance when
|
||||||
tasks, and checklists must make audience modes, raw/support
|
a repo-real route/action and permitted capability exist
|
||||||
gating, one dominant next action, and duplicate-truth prevention
|
- Added sections: None
|
||||||
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
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: add audience-aware disclosure
|
- .specify/templates/spec-template.md: require canonical UI-standard
|
||||||
section + constitution prompts ✅
|
compliance, no ad-hoc custom styling, and repo-real affordance
|
||||||
- .specify/templates/plan-template.md: add audience/disclosure
|
disclosure ✅
|
||||||
planning prompts + constitution checks ✅
|
- .specify/templates/plan-template.md: add UI-FIL-001 checks for the
|
||||||
- .specify/templates/tasks-template.md: add decision/disclosure
|
canonical UI standard and affordance honesty ✅
|
||||||
implementation + test tasks ✅
|
- .specify/templates/tasks-template.md: add implementation tasks for
|
||||||
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
no ad-hoc styling and repo-real interactive affordances ✅
|
||||||
one-primary-action, and duplicate-truth review checks ✅
|
- .specify/templates/checklist-template.md: add explicit custom UI
|
||||||
- docs/product/standards/README.md: refresh constitution index for
|
standard and affordance review check ✅
|
||||||
the new audience-aware disclosure contract ✅
|
- 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:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
@ -1710,6 +1711,7 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
|
|
||||||
### Filament Native First / No Ad-hoc Styling (UI-FIL-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.
|
- 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.
|
- 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.
|
- 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
|
- 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
|
hierarchy, progressive disclosure, accessibility, and overall
|
||||||
Filament visual language.
|
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-by-default classification
|
||||||
- `Native Surface` means the primary interaction contract is built from
|
- `Native Surface` means the primary interaction contract is built from
|
||||||
Filament-native components or approved shared primitives.
|
Filament-native components or approved shared primitives.
|
||||||
@ -1835,4 +1870,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 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
|
## 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.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament-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): 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
|
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
|
||||||
still necessary, they preserve dark mode correctness, spacing
|
still necessary, they preserve dark mode correctness, spacing
|
||||||
consistency, badge semantics, action hierarchy, progressive
|
consistency, badge semantics, action hierarchy, progressive
|
||||||
disclosure, accessibility, and Filament visual language
|
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
|
- 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
|
- Decision-first operating model (DECIDE-001): each changed
|
||||||
operator-facing surface is classified as Primary Decision,
|
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.
|
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:
|
**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,
|
- which native Filament components or shared UI primitives are used,
|
||||||
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
- 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 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.
|
- 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,
|
**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,
|
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
|
- 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,
|
- 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 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
|
- documenting any workflow-hub, wizard, utility/system, or other
|
||||||
special-type exception in the spec/PR and adding dedicated test
|
special-type exception in the spec/PR and adding dedicated test
|
||||||
coverage,
|
coverage,
|
||||||
|
|||||||
@ -378,9 +378,14 @@ ## AI Usage Note
|
|||||||
All AI agents must read:
|
All AI agents must read:
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `.specify/*`
|
- `.specify/*`
|
||||||
|
- `docs/ai-coding-rules.md`
|
||||||
|
- the relevant guideline file under `docs/*-guidelines.md`
|
||||||
|
|
||||||
before proposing or implementing changes.
|
before proposing or implementing changes.
|
||||||
|
|
||||||
|
For the current enterprise best-practice baseline and the proposed compact addendum
|
||||||
|
for this file, see `docs/stack-overview.md` and `docs/AGENTS-draft.md`.
|
||||||
|
|
||||||
## Reference Materials
|
## Reference Materials
|
||||||
- PowerShell scripts from IntuneManagement are stored under `/references/IntuneManagement-master`
|
- PowerShell scripts from IntuneManagement are stored under `/references/IntuneManagement-master`
|
||||||
for implementation guidance only.
|
for implementation guidance only.
|
||||||
|
|||||||
14
README.md
14
README.md
@ -31,7 +31,8 @@ ### Platform
|
|||||||
- Install PHP dependencies: `cd apps/platform && composer install`
|
- Install PHP dependencies: `cd apps/platform && composer install`
|
||||||
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
|
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
- 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 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`
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
@ -90,6 +91,16 @@ ### Workflow Expectation
|
|||||||
- Review treats wrong lane fit, hidden default cost, accidental heavy-family growth, or undocumented runtime drift as merge issues, not later cleanup.
|
- Review treats wrong lane fit, hidden default cost, accidental heavy-family growth, or undocumented runtime drift as merge issues, not later cleanup.
|
||||||
- Routine lane recalibration belongs inside the affecting feature spec or PR; open a dedicated follow-up spec only when recurring pain or structural lane changes justify it.
|
- Routine lane recalibration belongs inside the affecting feature spec or PR; open a dedicated follow-up spec only when recurring pain or structural lane changes justify it.
|
||||||
|
|
||||||
|
### Spec 288 Cutover Proof
|
||||||
|
|
||||||
|
- Spec `288` is an enforcement-only package. It owns the named no-legacy guards, the two named browser smokes, and contributor-facing quality-gate wording. It does not own runtime cutover repair, Package Execution work, or a full-suite stabilization pass.
|
||||||
|
- The pinned no-legacy heavy-governance proof is:
|
||||||
|
`export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec288NoLegacyRouteAndHelperGuardTest.php tests/Feature/Guards/Spec288ProviderCoreAndRoleAuthorityGuardTest.php tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php tests/Feature/ProviderConnections/LegacyRedirectTest.php tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php tests/Feature/Guards/BrowserLaneIsolationTest.php tests/Feature/Guards/CiLaneFailureClassificationContractTest.php tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php tests/Unit/Auth/NoRoleStringChecksTest.php)`
|
||||||
|
- The pinned browser proof is:
|
||||||
|
`export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php)`
|
||||||
|
- Keep the source-scan inventory narrow and explicit. The Spec `288` guard family is limited to the named route/helper and provider/role guard files; do not widen it with broad `tests/Feature/Guards` path allowlists or repo-wide tenant-panel helper bans.
|
||||||
|
- When `heavy-governance` or `browser` reports broader baseline fallout outside those named proofs, classify it in the lane output and follow up separately. Under Spec `288`, that broader fallout remains classification-only and does not by itself expand repair ownership.
|
||||||
|
|
||||||
### Authoring And Review Guardrails
|
### Authoring And Review Guardrails
|
||||||
|
|
||||||
- Start with the smallest honest surface: `Unit` for isolated logic, `Feature` for HTTP, Livewire, Filament, jobs, or non-browser integration, `heavy-governance` for intentionally expensive governance scans, and `Browser` only for end-to-end workflow coverage.
|
- Start with the smallest honest surface: `Unit` for isolated logic, `Feature` for HTTP, Livewire, Filament, jobs, or non-browser integration, `heavy-governance` for intentionally expensive governance scans, and `Browser` only for end-to-end workflow coverage.
|
||||||
@ -152,6 +163,7 @@ ### DB Reset and Seed Rules
|
|||||||
|
|
||||||
- Default lanes use SQLite `:memory:` with `RefreshDatabase` as the reset strategy.
|
- 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.
|
- 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.
|
- 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.
|
- 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\ProviderConnection;
|
||||||
use App\Models\ProviderCredential;
|
use App\Models\ProviderCredential;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\ProviderConnectionClassificationResult;
|
use App\Services\Providers\ProviderConnectionClassificationResult;
|
||||||
use App\Services\Providers\ProviderConnectionClassifier;
|
use App\Services\Providers\ProviderConnectionClassifier;
|
||||||
@ -43,9 +43,9 @@ public function handle(ProviderConnectionClassifier $classifier): int
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantCounts = (clone $query)
|
$tenantCounts = (clone $query)
|
||||||
->selectRaw('tenant_id, count(*) as aggregate')
|
->selectRaw('managed_environment_id, count(*) as aggregate')
|
||||||
->groupBy('tenant_id')
|
->groupBy('managed_environment_id')
|
||||||
->pluck('aggregate', 'tenant_id')
|
->pluck('aggregate', 'managed_environment_id')
|
||||||
->map(static fn (mixed $count): int => (int) $count)
|
->map(static fn (mixed $count): int => (int) $count)
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ public function handle(ProviderConnectionClassifier $classifier): int
|
|||||||
|
|
||||||
$tenant = $connection->tenant;
|
$tenant = $connection->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
|
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -123,11 +123,11 @@ private function query(): Builder
|
|||||||
$tenantOption = $this->option('tenant');
|
$tenantOption = $this->option('tenant');
|
||||||
|
|
||||||
if (is_string($tenantOption) && trim($tenantOption) !== '') {
|
if (is_string($tenantOption) && trim($tenantOption) !== '') {
|
||||||
$tenant = Tenant::query()
|
$tenant = ManagedEnvironment::query()
|
||||||
->forTenant(trim($tenantOption))
|
->forTenant(trim($tenantOption))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$query->where('tenant_id', (int) $tenant->getKey());
|
$query->where('managed_environment_id', (int) $tenant->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
$connectionOption = $this->option('connection');
|
$connectionOption = $this->option('connection');
|
||||||
@ -175,7 +175,7 @@ private function applyClassification(
|
|||||||
return $connection->fresh(['tenant', 'credential']);
|
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(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -195,7 +195,7 @@ private function auditStart(Tenant $tenant, int $candidateCount): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function auditApplied(
|
private function auditApplied(
|
||||||
Tenant $tenant,
|
ManagedEnvironment $tenant,
|
||||||
ProviderConnection $connection,
|
ProviderConnection $connection,
|
||||||
ProviderConnectionClassificationResult $result,
|
ProviderConnectionClassificationResult $result,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ class OpsReconcileAdapterRuns extends Command
|
|||||||
*/
|
*/
|
||||||
protected $signature = 'ops:reconcile-adapter-runs
|
protected $signature = 'ops:reconcile-adapter-runs
|
||||||
{--type= : Adapter run type (e.g. restore.execute)}
|
{--type= : Adapter run type (e.g. restore.execute)}
|
||||||
{--tenant= : Tenant ID}
|
{--tenant= : ManagedEnvironment ID}
|
||||||
{--older-than=60 : Only consider runs older than N minutes}
|
{--older-than=60 : Only consider runs older than N minutes}
|
||||||
{--dry-run=true : Preview only (true/false)}
|
{--dry-run=true : Preview only (true/false)}
|
||||||
{--limit=50 : Max number of runs to inspect}';
|
{--limit=50 : Max number of runs to inspect}';
|
||||||
@ -56,7 +56,7 @@ public function handle()
|
|||||||
|
|
||||||
$result = $reconciler->reconcile([
|
$result = $reconciler->reconcile([
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'tenant_id' => $tenantId,
|
'managed_environment_id' => $tenantId,
|
||||||
'older_than_minutes' => $olderThanMinutes,
|
'older_than_minutes' => $olderThanMinutes,
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
'dry_run' => $dryRun,
|
'dry_run' => $dryRun,
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
@ -51,7 +51,7 @@ public function handle(): int
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($tenantIds !== []) {
|
if ($tenantIds !== []) {
|
||||||
$query->whereIn('tenant_id', $tenantIds);
|
$query->whereIn('managed_environment_id', $tenantIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidates = $query->get();
|
$candidates = $query->get();
|
||||||
@ -66,12 +66,12 @@ public function handle(): int
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->table(
|
$this->table(
|
||||||
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
['Run', 'Type', 'ManagedEnvironment', 'Workspace', 'Legacy signal'],
|
||||||
$matched
|
$matched
|
||||||
->map(fn (OperationRun $run): array => [
|
->map(fn (OperationRun $run): array => [
|
||||||
'Run' => (string) $run->getKey(),
|
'Run' => (string) $run->getKey(),
|
||||||
'Type' => (string) $run->type,
|
'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 : '—',
|
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
||||||
'Legacy signal' => $this->legacySignal($run),
|
'Legacy signal' => $this->legacySignal($run),
|
||||||
])
|
])
|
||||||
@ -145,9 +145,9 @@ private function resolveTenantIds(array $tenantIdentifiers): array
|
|||||||
$tenantIds = [];
|
$tenantIds = [];
|
||||||
|
|
||||||
foreach ($tenantIdentifiers as $identifier) {
|
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();
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@ -43,14 +43,14 @@ public function handle(): int
|
|||||||
->where('policy_type', 'enrollmentRestriction');
|
->where('policy_type', 'enrollmentRestriction');
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$query->where('tenant_id', $tenant->id);
|
$query->where('managed_environment_id', $tenant->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidates = $query->get();
|
$candidates = $query->get();
|
||||||
|
|
||||||
$changedVersions = 0;
|
$changedVersions = 0;
|
||||||
$changedPolicies = 0;
|
$changedPolicies = 0;
|
||||||
$ignoredPolicies = 0;
|
$providerMissingPolicies = 0;
|
||||||
|
|
||||||
foreach ($candidates as $policy) {
|
foreach ($candidates as $policy) {
|
||||||
$latestVersion = $policy->versions()->latest('version_number')->first();
|
$latestVersion = $policy->versions()->latest('version_number')->first();
|
||||||
@ -69,9 +69,9 @@ public function handle(): int
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->line(sprintf(
|
$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->getKey(),
|
||||||
(string) $policy->tenant_id,
|
(string) $policy->managed_environment_id,
|
||||||
(string) $policy->external_id,
|
(string) $policy->external_id,
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -80,20 +80,21 @@ public function handle(): int
|
|||||||
}
|
}
|
||||||
|
|
||||||
$existingTarget = Policy::query()
|
$existingTarget = Policy::query()
|
||||||
->where('tenant_id', $policy->tenant_id)
|
->where('managed_environment_id', $policy->managed_environment_id)
|
||||||
->where('external_id', $policy->external_id)
|
->where('external_id', $policy->external_id)
|
||||||
->where('policy_type', 'windowsEnrollmentStatusPage')
|
->where('policy_type', 'windowsEnrollmentStatusPage')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existingTarget) {
|
if ($existingTarget) {
|
||||||
$policy->forceFill(['ignored_at' => now()])->save();
|
$policy->forceFill(['missing_from_provider_at' => now()])->save();
|
||||||
$ignoredPolicies++;
|
$providerMissingPolicies++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$policy->forceFill([
|
$policy->forceFill([
|
||||||
'policy_type' => 'windowsEnrollmentStatusPage',
|
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||||
|
'missing_from_provider_at' => null,
|
||||||
])->save();
|
])->save();
|
||||||
$changedPolicies++;
|
$changedPolicies++;
|
||||||
|
|
||||||
@ -106,7 +107,7 @@ public function handle(): int
|
|||||||
$this->info('Done.');
|
$this->info('Done.');
|
||||||
$this->info('PolicyVersions changed: '.$changedVersions);
|
$this->info('PolicyVersions changed: '.$changedVersions);
|
||||||
$this->info('Policies changed: '.$changedPolicies);
|
$this->info('Policies changed: '.$changedPolicies);
|
||||||
$this->info('Policies ignored: '.$ignoredPolicies);
|
$this->info('Policies marked provider-missing: '.$providerMissingPolicies);
|
||||||
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
@ -129,7 +130,7 @@ private function fetchSnapshotOrNull(Policy $policy): ?array
|
|||||||
return null;
|
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, [
|
$response = $this->graphClient->getPolicy('enrollmentRestriction', $policy->external_id, [
|
||||||
'tenant' => $tenantIdentifier,
|
'tenant' => $tenantIdentifier,
|
||||||
@ -147,7 +148,7 @@ private function fetchSnapshotOrNull(Policy $policy): ?array
|
|||||||
return is_array($payload) ? $payload : null;
|
return is_array($payload) ? $payload : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTenantOrNull(): ?Tenant
|
private function resolveTenantOrNull(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenantOption = $this->option('tenant');
|
$tenantOption = $this->option('tenant');
|
||||||
|
|
||||||
@ -155,7 +156,7 @@ private function resolveTenantOrNull(): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->forTenant($tenantOption)
|
->forTenant($tenantOption)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,17 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\ManagedEnvironmentMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -42,7 +44,7 @@ public function handle(): int
|
|||||||
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
||||||
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
||||||
$scenarioConfig = is_array($fixture['blocked_drillthrough'] ?? null) ? $fixture['blocked_drillthrough'] : [];
|
$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(
|
$workspace = Workspace::query()->updateOrCreate(
|
||||||
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
|
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
|
||||||
@ -60,16 +62,13 @@ public function handle(): int
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$tenant = Tenant::query()->updateOrCreate(
|
$tenant = ManagedEnvironment::query()->updateOrCreate(
|
||||||
['external_id' => $tenantRouteKey],
|
['slug' => $tenantRouteKey],
|
||||||
[
|
[
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup ManagedEnvironment'),
|
||||||
'tenant_id' => $tenantRouteKey,
|
'lifecycle_status' => ManagedEnvironment::STATUS_ACTIVE,
|
||||||
'app_certificate_thumbprint' => null,
|
'kind' => 'dev',
|
||||||
'app_notes' => null,
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'environment' => 'dev',
|
|
||||||
'is_current' => false,
|
'is_current' => false,
|
||||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||||
'rbac_status' => 'ok',
|
'rbac_status' => 'ok',
|
||||||
@ -82,8 +81,8 @@ public function handle(): int
|
|||||||
['role' => 'owner'],
|
['role' => 'owner'],
|
||||||
);
|
);
|
||||||
|
|
||||||
TenantMembership::query()->updateOrCreate(
|
ManagedEnvironmentMembership::query()->updateOrCreate(
|
||||||
['tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
|
['managed_environment_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
|
||||||
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-180-browser-smoke'],
|
['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();
|
$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(
|
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()],
|
['last_used_at' => now()],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$policy = Policy::query()->updateOrCreate(
|
$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'),
|
'external_id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||||
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||||
],
|
],
|
||||||
@ -113,7 +112,7 @@ public function handle(): int
|
|||||||
);
|
);
|
||||||
|
|
||||||
$backupSet = BackupSet::withTrashed()->firstOrNew([
|
$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'),
|
'name' => (string) ($scenarioConfig['backup_set_name'] ?? 'Spec 180 Blocked Stale Backup'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -137,7 +136,7 @@ public function handle(): int
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$backupItem->forceFill([
|
$backupItem->forceFill([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
'policy_id' => (int) $policy->getKey(),
|
'policy_id' => (int) $policy->getKey(),
|
||||||
'platform' => 'windows',
|
'platform' => 'windows',
|
||||||
'captured_at' => $backupSet->completed_at,
|
'captured_at' => $backupSet->completed_at,
|
||||||
@ -173,11 +172,11 @@ public function handle(): int
|
|||||||
['Workspace', (string) $workspace->name],
|
['Workspace', (string) $workspace->name],
|
||||||
['User email', (string) $user->email],
|
['User email', (string) $user->email],
|
||||||
['User password', $password],
|
['User password', $password],
|
||||||
['Tenant', (string) $tenant->name],
|
['ManagedEnvironment', (string) $tenant->name],
|
||||||
['Tenant external id', (string) $tenant->external_id],
|
['ManagedEnvironment external id', (string) $tenant->external_id],
|
||||||
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
|
['Dashboard URL', ManagedEnvironmentLinks::viewUrl($tenant)],
|
||||||
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
|
['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'],
|
['Locally denied capability', 'tenant.view'],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class SyncPolicies extends Command
|
class SyncPolicies extends Command
|
||||||
@ -24,16 +24,16 @@ public function handle(): int
|
|||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTenant(): Tenant
|
private function resolveTenant(): ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenantId = $this->option('tenant');
|
$tenantId = $this->option('tenant');
|
||||||
|
|
||||||
if ($tenantId) {
|
if ($tenantId) {
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->forTenant($tenantId)
|
->forTenant($tenantId)
|
||||||
->firstOrFail();
|
->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();
|
$missing = (int) DB::table($table)->whereNull('workspace_id')->count();
|
||||||
|
|
||||||
$unresolvableQuery = DB::table($table)
|
$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))
|
->whereNull(sprintf('%s.workspace_id', $table))
|
||||||
->where(function ($query): void {
|
->where(function ($query): void {
|
||||||
$query->whereNull('tenants.id')
|
$query->whereNull('managed_environments.id')
|
||||||
->orWhereNull('tenants.workspace_id');
|
->orWhereNull('managed_environments.workspace_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
$unresolvable = (int) $unresolvableQuery->count();
|
$unresolvable = (int) $unresolvableQuery->count();
|
||||||
|
|
||||||
$sampleIds = DB::table($table)
|
$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))
|
->whereNull(sprintf('%s.workspace_id', $table))
|
||||||
->where(function ($query): void {
|
->where(function ($query): void {
|
||||||
$query->whereNull('tenants.id')
|
$query->whereNull('managed_environments.id')
|
||||||
->orWhereNull('tenants.workspace_id');
|
->orWhereNull('managed_environments.workspace_id');
|
||||||
})
|
})
|
||||||
->orderBy(sprintf('%s.id', $table))
|
->orderBy(sprintf('%s.id', $table))
|
||||||
->limit(5)
|
->limit(5)
|
||||||
@ -302,11 +302,11 @@ private function collectWorkspaceWorkloads(array $tables, ?int $maxRows): array
|
|||||||
|
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
$rows = DB::table($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))
|
->whereNull(sprintf('%s.workspace_id', $table))
|
||||||
->whereNotNull('tenants.workspace_id')
|
->whereNotNull('managed_environments.workspace_id')
|
||||||
->selectRaw('tenants.workspace_id as workspace_id, COUNT(*) as row_count')
|
->selectRaw('managed_environments.workspace_id as workspace_id, COUNT(*) as row_count')
|
||||||
->groupBy('tenants.workspace_id')
|
->groupBy('managed_environments.workspace_id')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
class TenantpilotDispatchBackupSchedules extends Command
|
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).';
|
protected $description = 'Dispatch due backup schedules (idempotent per schedule minute-slot).';
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
class TenantpilotDispatchDirectoryGroupsSync extends Command
|
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).';
|
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
|
private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection
|
||||||
{
|
{
|
||||||
$query = Tenant::activeQuery();
|
$query = ManagedEnvironment::activeQuery();
|
||||||
|
|
||||||
if ($tenantIdentifiers !== []) {
|
if ($tenantIdentifiers !== []) {
|
||||||
$query->where(function ($subQuery) use ($tenantIdentifiers) {
|
$query->where(function ($subQuery) use ($tenantIdentifiers) {
|
||||||
@ -107,8 +107,7 @@ private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\C
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subQuery->orWhere('tenant_id', $identifier)
|
$subQuery->orWhere('slug', $identifier);
|
||||||
->orWhere('external_id', $identifier);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -26,7 +26,7 @@ class TenantpilotPurgeNonPersistentData extends Command
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'tenantpilot:purge-nonpersistent
|
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}
|
{--all : Purge for all tenants}
|
||||||
{--force : Actually delete rows}';
|
{--force : Actually delete rows}';
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ public function handle(): int
|
|||||||
$counts = $this->countsForTenant($tenant);
|
$counts = $this->countsForTenant($tenant);
|
||||||
|
|
||||||
$this->line('');
|
$this->line('');
|
||||||
$this->info("Tenant: {$tenant->id} ({$tenant->name})");
|
$this->info("ManagedEnvironment: {$tenant->id} ({$tenant->name})");
|
||||||
$this->table(
|
$this->table(
|
||||||
['Table', 'Rows'],
|
['Table', 'Rows'],
|
||||||
collect($counts)
|
collect($counts)
|
||||||
@ -83,31 +83,31 @@ public function handle(): int
|
|||||||
|
|
||||||
DB::transaction(function () use ($tenant): void {
|
DB::transaction(function () use ($tenant): void {
|
||||||
BackupSchedule::query()
|
BackupSchedule::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
OperationRun::query()
|
OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
RestoreRun::withTrashed()
|
RestoreRun::withTrashed()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
->forceDelete();
|
->forceDelete();
|
||||||
|
|
||||||
BackupItem::withTrashed()
|
BackupItem::withTrashed()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
->forceDelete();
|
->forceDelete();
|
||||||
|
|
||||||
BackupSet::withTrashed()
|
BackupSet::withTrashed()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
->forceDelete();
|
->forceDelete();
|
||||||
|
|
||||||
PolicyVersion::withTrashed()
|
PolicyVersion::withTrashed()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
->forceDelete();
|
->forceDelete();
|
||||||
|
|
||||||
Policy::query()
|
Policy::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
->delete();
|
->delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -122,19 +122,19 @@ public function handle(): int
|
|||||||
private function resolveTenants()
|
private function resolveTenants()
|
||||||
{
|
{
|
||||||
if ((bool) $this->option('all')) {
|
if ((bool) $this->option('all')) {
|
||||||
return Tenant::query()->get();
|
return ManagedEnvironment::query()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantArg = $this->argument('tenant');
|
$tenantArg = $this->argument('tenant');
|
||||||
|
|
||||||
if ($tenantArg !== null && $tenantArg !== '') {
|
if ($tenantArg !== null && $tenantArg !== '') {
|
||||||
$tenant = Tenant::query()->forTenant($tenantArg)->first();
|
$tenant = ManagedEnvironment::query()->forTenant($tenantArg)->first();
|
||||||
|
|
||||||
return $tenant ? collect([$tenant]) : collect();
|
return $tenant ? collect([$tenant]) : collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return collect([Tenant::currentOrFail()]);
|
return collect([ManagedEnvironment::currentOrFail()]);
|
||||||
} catch (RuntimeException) {
|
} catch (RuntimeException) {
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
@ -143,30 +143,30 @@ private function resolveTenants()
|
|||||||
/**
|
/**
|
||||||
* @return array<string,int>
|
* @return array<string,int>
|
||||||
*/
|
*/
|
||||||
private function countsForTenant(Tenant $tenant): array
|
private function countsForTenant(ManagedEnvironment $tenant): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
'backup_schedules' => BackupSchedule::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
'operation_runs' => OperationRun::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
'audit_logs_retained' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
'audit_logs_retained' => AuditLog::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
'restore_runs' => RestoreRun::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
'backup_items' => BackupItem::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
'backup_sets' => BackupSet::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
'policy_versions' => PolicyVersion::withTrashed()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
'policies' => Policy::query()->where('managed_environment_id', $tenant->id)->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, int> $counts
|
* @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']);
|
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
|
||||||
|
|
||||||
OperationRun::query()->create([
|
OperationRun::query()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'tenant_id' => (int) $tenant->id,
|
'managed_environment_id' => (int) $tenant->id,
|
||||||
'user_id' => null,
|
'user_id' => null,
|
||||||
'initiator_name' => 'System',
|
'initiator_name' => 'System',
|
||||||
'type' => OperationRunType::BackupSchedulePurge->value,
|
'type' => OperationRunType::BackupSchedulePurge->value,
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
@ -15,7 +15,7 @@
|
|||||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
|
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}
|
{--older-than=5 : Only reconcile runs older than N minutes}
|
||||||
{--dry-run : Do not write changes}';
|
{--dry-run : Do not write changes}';
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ public function handle(
|
|||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->whereIn('tenant_id', $tenantIds);
|
$query->whereIn('managed_environment_id', $tenantIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$reconciled = 0;
|
$reconciled = 0;
|
||||||
@ -78,7 +78,7 @@ public function handle(
|
|||||||
|
|
||||||
$schedule = BackupSchedule::query()
|
$schedule = BackupSchedule::query()
|
||||||
->whereKey((int) $backupScheduleId)
|
->whereKey((int) $backupScheduleId)
|
||||||
->where('tenant_id', (int) $operationRun->tenant_id)
|
->where('managed_environment_id', (int) $operationRun->managed_environment_id)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $schedule instanceof BackupSchedule) {
|
if (! $schedule instanceof BackupSchedule) {
|
||||||
@ -135,7 +135,7 @@ private function resolveTenantIds(array $tenantIdentifiers): array
|
|||||||
$tenantIds = [];
|
$tenantIds = [];
|
||||||
|
|
||||||
foreach ($tenantIdentifiers as $identifier) {
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
$tenant = Tenant::query()
|
$tenant = ManagedEnvironment::query()
|
||||||
->forTenant($identifier)
|
->forTenant($identifier)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
@ -13,7 +13,7 @@ class TenantpilotReconcileOperationRuns extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'tenantpilot:operation-runs:reconcile
|
protected $signature = 'tenantpilot:operation-runs:reconcile
|
||||||
{--type=* : Limit reconciliation to one or more covered operation types}
|
{--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}
|
{--workspace=* : Limit reconciliation to workspace ids}
|
||||||
{--limit=100 : Maximum number of active runs to inspect}
|
{--limit=100 : Maximum number of active runs to inspect}
|
||||||
{--dry-run : Report the changes without writing them}';
|
{--dry-run : Report the changes without writing them}';
|
||||||
@ -93,9 +93,9 @@ private function resolveTenantIds(array $tenantIdentifiers): array
|
|||||||
$tenantIds = [];
|
$tenantIds = [];
|
||||||
|
|
||||||
foreach ($tenantIdentifiers as $identifier) {
|
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();
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ public function handle(PolicySnapshotService $snapshotService): int
|
|||||||
|
|
||||||
// Create PolicyVersion to save the snapshot
|
// Create PolicyVersion to save the snapshot
|
||||||
$policy->versions()->create([
|
$policy->versions()->create([
|
||||||
'tenant_id' => $policy->tenant_id,
|
'managed_environment_id' => $policy->managed_environment_id,
|
||||||
'version_number' => $policy->versions()->max('version_number') + 1,
|
'version_number' => $policy->versions()->max('version_number') + 1,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
namespace App\Contracts\Hardening;
|
namespace App\Contracts\Hardening;
|
||||||
|
|
||||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
|
|
||||||
interface WriteGateInterface
|
interface WriteGateInterface
|
||||||
{
|
{
|
||||||
@ -12,12 +12,12 @@ interface WriteGateInterface
|
|||||||
*
|
*
|
||||||
* @throws ProviderAccessHardeningRequired when the operation is blocked
|
* @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.
|
* Check whether the gate would block a write operation for the given tenant.
|
||||||
*
|
*
|
||||||
* Non-throwing variant for UI disabled-state checks.
|
* Non-throwing variant for UI disabled-state checks.
|
||||||
*/
|
*/
|
||||||
public function wouldBlock(Tenant $tenant): bool;
|
public function wouldBlock(ManagedEnvironment $tenant): bool;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,16 @@
|
|||||||
|
|
||||||
namespace App\Filament\Clusters\Inventory;
|
namespace App\Filament\Clusters\Inventory;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Clusters\Cluster;
|
use Filament\Clusters\Cluster;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Enums\SubNavigationPosition;
|
use Filament\Pages\Enums\SubNavigationPosition;
|
||||||
|
use Filament\Panel;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventoryCluster extends Cluster
|
class InventoryCluster extends Cluster
|
||||||
@ -22,10 +28,92 @@ class InventoryCluster extends Cluster
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSlug(?Panel $panel = null): string
|
||||||
|
{
|
||||||
|
$panelId = $panel?->getId() ?? Filament::getCurrentOrDefaultPanel()?->getId();
|
||||||
|
|
||||||
|
if ($panelId !== 'admin') {
|
||||||
|
return parent::getSlug($panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
return 'workspaces/{workspace}/environments/{environment}/inventory';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $parameters
|
||||||
|
*/
|
||||||
|
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string
|
||||||
|
{
|
||||||
|
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||||
|
|
||||||
|
if ($panelId !== 'admin') {
|
||||||
|
return parent::getUrl($parameters, $isAbsolute, $panelId, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedTenant = static::resolveAdminUrlTenant($parameters, $tenant);
|
||||||
|
|
||||||
|
if (! $resolvedTenant instanceof ManagedEnvironment) {
|
||||||
|
return url('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = static::resolveAdminUrlWorkspace($resolvedTenant, $parameters);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace && ! is_string($workspace) && ! is_int($workspace)) {
|
||||||
|
return url('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters['environment'] ??= $resolvedTenant;
|
||||||
|
$parameters['workspace'] ??= $workspace;
|
||||||
|
unset($parameters['tenant']);
|
||||||
|
|
||||||
|
return parent::getUrl($parameters, $isAbsolute, $panelId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $parameters
|
||||||
|
*/
|
||||||
|
private static function resolveAdminUrlTenant(array $parameters, ?Model $tenant = null): ?ManagedEnvironment
|
||||||
|
{
|
||||||
|
$parameterTenant = $parameters['tenant'] ?? $parameters['environment'] ?? null;
|
||||||
|
|
||||||
|
if ($parameterTenant instanceof ManagedEnvironment) {
|
||||||
|
return $parameterTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if ($filamentTenant instanceof ManagedEnvironment) {
|
||||||
|
return $filamentTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $parameters
|
||||||
|
*/
|
||||||
|
private static function resolveAdminUrlWorkspace(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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Concerns;
|
namespace App\Filament\Concerns;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
|
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
|
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -23,7 +23,7 @@ protected static function tenantOwnedRelationshipName(): string
|
|||||||
: 'tenant';
|
: 'tenant';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
|
protected static function resolveTenantContextForTenantOwnedRecords(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
||||||
return static::resolveTenantContextForCurrentPanel();
|
return static::resolveTenantContextForCurrentPanel();
|
||||||
@ -41,7 +41,7 @@ public static function getTenantOwnedEloquentQuery(): Builder
|
|||||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
|
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(
|
return app(TenantOwnedQueryScope::class)->apply(
|
||||||
$query,
|
$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(
|
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||||
$query ?? parent::getEloquentQuery(),
|
$query ?? parent::getEloquentQuery(),
|
||||||
@ -60,7 +60,7 @@ protected static function resolveTenantOwnedRecord(Model|int|string|null $record
|
|||||||
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $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(
|
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||||
$query ?? parent::getEloquentQuery(),
|
$query ?? parent::getEloquentQuery(),
|
||||||
|
|||||||
@ -4,50 +4,50 @@
|
|||||||
|
|
||||||
namespace App\Filament\Concerns;
|
namespace App\Filament\Concerns;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
trait ResolvesPanelTenantContext
|
trait ResolvesPanelTenantContext
|
||||||
{
|
{
|
||||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
protected static function resolveTenantContextForCurrentPanel(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$request = request();
|
$request = request();
|
||||||
|
|
||||||
if (static::currentPanelId($request) === 'admin') {
|
if (static::currentPanelId($request) === 'admin') {
|
||||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
$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();
|
return static::resolveTenantContextForCurrentPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function trustedPanelTenantContext(): ?Tenant
|
public static function trustedPanelTenantContext(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
return static::panelTenantContext();
|
return static::panelTenantContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
protected static function resolveTenantContextForCurrentPanelOrFail(): ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
throw new RuntimeException('No tenant context selected.');
|
throw new RuntimeException('No tenant context selected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
protected static function resolveTrustedPanelTenantContextOrFail(): ManagedEnvironment
|
||||||
{
|
{
|
||||||
return static::resolveTenantContextForCurrentPanelOrFail();
|
return static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
}
|
}
|
||||||
@ -65,10 +65,6 @@ private static function currentPanelId(mixed $request): ?string
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (is_string($routeName) && $routeName !== '') {
|
if (is_string($routeName) && $routeName !== '') {
|
||||||
if (str_contains($routeName, '.tenant.')) {
|
|
||||||
return 'tenant';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($routeName, '.admin.')) {
|
if (str_contains($routeName, '.admin.')) {
|
||||||
return 'admin';
|
return 'admin';
|
||||||
}
|
}
|
||||||
@ -78,10 +74,6 @@ private static function currentPanelId(mixed $request): ?string
|
|||||||
? '/'.ltrim((string) $request->path(), '/')
|
? '/'.ltrim((string) $request->path(), '/')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
|
|
||||||
return 'tenant';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($path) && str_starts_with($path, '/admin/')) {
|
if (is_string($path) && str_starts_with($path, '/admin/')) {
|
||||||
return 'admin';
|
return 'admin';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Concerns;
|
namespace App\Filament\Concerns;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -54,7 +54,7 @@ protected static function resolveGlobalSearchTenant(): ?Model
|
|||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|||||||
@ -0,0 +1,152 @@
|
|||||||
|
<?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['environment'] ??= $resolvedTenant;
|
||||||
|
$parameters['workspace'] ??= $workspace;
|
||||||
|
unset($parameters['tenant']);
|
||||||
|
|
||||||
|
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/{environment}/';
|
||||||
|
|
||||||
|
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\Filament\Resources\FindingResource;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
@ -18,6 +18,7 @@
|
|||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -105,6 +106,12 @@ class BaselineCompareLanding extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.baseline-compare-landing';
|
protected string $view = 'filament.pages.baseline-compare-landing';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
|
&& parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public ?string $state = null;
|
public ?string $state = null;
|
||||||
|
|
||||||
public ?string $message = null;
|
public ?string $message = null;
|
||||||
@ -185,7 +192,7 @@ public static function canAccess(): bool
|
|||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,7 +224,7 @@ public function refreshStats(): void
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
$aggregate = $tenant instanceof Tenant
|
$aggregate = $tenant instanceof ManagedEnvironment
|
||||||
? $this->governanceAggregate($tenant, $stats)
|
? $this->governanceAggregate($tenant, $stats)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -442,7 +449,7 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
|
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -509,7 +516,7 @@ public function getFindingsUrl(): ?string
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,7 +531,7 @@ public function getRunUrl(): ?string
|
|||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -551,7 +558,7 @@ public function openCompareMatrixUrl(): ?string
|
|||||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
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 */
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||||
@ -575,7 +582,7 @@ private function resolveCompareMatrixProfile(): ?BaselineProfile
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Filament\Resources\BaselineProfileResource;
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
@ -200,7 +200,7 @@ class BaselineCompareMatrix extends Page implements HasForms
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned environments.')
|
||||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
|
||||||
@ -283,7 +283,7 @@ public function form(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('draftTenantSort')
|
Select::make('draftTenantSort')
|
||||||
->label('Tenant sort')
|
->label('ManagedEnvironment sort')
|
||||||
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
||||||
->default('tenant_name')
|
->default('tenant_name')
|
||||||
->native(false)
|
->native(false)
|
||||||
@ -341,11 +341,11 @@ protected function getHeaderActions(): array
|
|||||||
$profile = $this->getRecord();
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
|
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
|
||||||
->label('Compare assigned tenants')
|
->label('Compare assigned environments')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Compare assigned tenants')
|
->modalHeading('Compare assigned environments')
|
||||||
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
|
->modalDescription('Simulation only. This starts the normal environment-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
|
||||||
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
|
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
|
||||||
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
|
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
|
||||||
->action(fn (): mixed => $this->compareAssignedTenants());
|
->action(fn (): mixed => $this->compareAssignedTenants());
|
||||||
@ -441,13 +441,12 @@ public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?st
|
|||||||
{
|
{
|
||||||
$tenant = $this->tenant($tenantId);
|
$tenant = $this->tenant($tenantId);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BaselineCompareLanding::getUrl(
|
return BaselineCompareLanding::getUrl(
|
||||||
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
|
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
|
||||||
panel: 'tenant',
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -456,7 +455,7 @@ public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey =
|
|||||||
{
|
{
|
||||||
$tenant = $this->tenant($tenantId);
|
$tenant = $this->tenant($tenantId);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -573,7 +572,7 @@ public function stagedFilterSummary(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->draftTenantSort !== $this->tenantSort) {
|
if ($this->draftTenantSort !== $this->tenantSort) {
|
||||||
$summary['Tenant sort'] = $this->draftTenantSort;
|
$summary['ManagedEnvironment sort'] = $this->draftTenantSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->draftSubjectSort !== $this->subjectSort) {
|
if ($this->draftSubjectSort !== $this->subjectSort) {
|
||||||
@ -735,11 +734,11 @@ private function compareAssignedTenantsDisabledReason(): ?string
|
|||||||
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
||||||
|
|
||||||
if (($reference['referenceState'] ?? null) !== 'ready') {
|
if (($reference['referenceState'] ?? null) !== 'ready') {
|
||||||
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
|
return 'Capture a complete baseline snapshot before comparing assigned environments.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
|
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
|
||||||
return 'No visible assigned tenants are available for compare.';
|
return 'No visible assigned environments are available for compare.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->compareStartReasonMessage($this->compareAssignedTenantsReasonCode());
|
return $this->compareStartReasonMessage($this->compareAssignedTenantsReasonCode());
|
||||||
@ -769,10 +768,10 @@ private function compareAssignedTenantsReasonCode(): ?string
|
|||||||
private function compareStartReasonMessage(?string $reasonCode): ?string
|
private function compareStartReasonMessage(?string $reasonCode): ?string
|
||||||
{
|
{
|
||||||
return match ($reasonCode) {
|
return match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before comparing assigned tenants.',
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before comparing assigned environments.',
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy yet.',
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy yet.',
|
||||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned tenants.',
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned environments.',
|
||||||
'tenant_sync_required' => 'You need tenant sync access for each visible tenant before compare can start.',
|
'tenant_sync_required' => 'You need environment sync access for each visible environment before compare can start.',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -855,7 +854,7 @@ private function routeParameters(array $overrides = []): array
|
|||||||
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
], 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 */
|
/** @var BaselineProfile $profile */
|
||||||
$profile = $this->getRecord();
|
$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)
|
->whereKey($tenantId)
|
||||||
->where('workspace_id', (int) $this->getRecord()->workspace_id)
|
->where('workspace_id', (int) $this->getRecord()->workspace_id)
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
@ -17,7 +18,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class ChooseTenant extends Page
|
class ChooseEnvironment extends Page
|
||||||
{
|
{
|
||||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||||
|
|
||||||
@ -25,11 +26,14 @@ class ChooseTenant extends Page
|
|||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'choose-tenant';
|
protected static ?string $slug = 'choose-environment';
|
||||||
|
|
||||||
protected static ?string $title = 'Choose tenant';
|
protected string $view = 'filament.pages.choose-environment';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.choose-tenant';
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return __('localization.shell.choose_environment');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable the simple-layout topbar to prevent lazy-loaded
|
* Disable the simple-layout topbar to prevent lazy-loaded
|
||||||
@ -43,14 +47,14 @@ protected function getLayoutData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Tenant>
|
* @return Collection<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function getTenants(): Collection
|
public function getTenants(): Collection
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
return ManagedEnvironment::query()->whereRaw('1 = 0')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
||||||
@ -62,7 +66,7 @@ public function getTenants(): Collection
|
|||||||
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
|
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function selectTenant(int $tenantId): void
|
public function selectEnvironment(int $tenantId): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -75,9 +79,9 @@ public function selectTenant(int $tenantId): void
|
|||||||
$tenant = null;
|
$tenant = null;
|
||||||
|
|
||||||
if ($workspaceId === 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;
|
$workspace = $tenant->workspace;
|
||||||
|
|
||||||
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
||||||
@ -93,14 +97,14 @@ public function selectTenant(int $tenantId): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
|
if (! $tenant instanceof ManagedEnvironment || (int) $tenant->workspace_id !== $workspaceId) {
|
||||||
$tenant = Tenant::query()
|
$tenant = ManagedEnvironment::query()
|
||||||
->where('workspace_id', $workspaceId)
|
->where('workspace_id', $workspaceId)
|
||||||
->whereKey($tenantId)
|
->whereKey($tenantId)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,15 +130,15 @@ public function selectTenant(int $tenantId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
$this->redirect(ManagedEnvironmentLinks::viewUrl($tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
public function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation
|
||||||
{
|
{
|
||||||
return TenantLifecyclePresentation::fromTenant($tenant);
|
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')) {
|
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||||
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
||||||
@ -142,12 +146,12 @@ private function persistLastTenant(User $user, Tenant $tenant): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Schema::hasTable('user_tenant_preferences')) {
|
if (! Schema::hasTable('user_managed_environment_preferences')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UserTenantPreference::query()->updateOrCreate(
|
UserTenantPreference::query()->updateOrCreate(
|
||||||
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
|
['user_id' => $user->getKey(), 'managed_environment_id' => $tenant->getKey()],
|
||||||
['last_used_at' => now()]
|
['last_used_at' => now()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -63,8 +63,10 @@ public function getWorkspaces(): Collection
|
|||||||
->where('user_id', $user->getKey());
|
->where('user_id', $user->getKey());
|
||||||
})
|
})
|
||||||
->whereNull('archived_at')
|
->whereNull('archived_at')
|
||||||
|
->whereNull('closed_at')
|
||||||
->withCount(['tenants' => function ($query): void {
|
->withCount(['tenants' => function ($query): void {
|
||||||
$query->where('status', 'active');
|
$query->where('lifecycle_status', 'active')
|
||||||
|
->whereNull('removed_from_workspace_at');
|
||||||
}])
|
}])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
@ -94,7 +96,7 @@ public function selectWorkspace(int $workspaceId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($workspace->archived_at)) {
|
if (! $workspace->isSelectableAsContext()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,43 +4,51 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\PortfolioCompare\CrossEnvironmentPromotionExecutionService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
|
use App\Support\PortfolioCompare\CrossEnvironmentComparePreviewBuilder;
|
||||||
|
use App\Support\PortfolioCompare\CrossEnvironmentCompareSelection;
|
||||||
|
use App\Support\PortfolioCompare\CrossEnvironmentPromotionPreflight;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use DomainException;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\Grid;
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class CrossTenantComparePage extends Page implements HasForms
|
class CrossEnvironmentComparePage extends Page implements HasForms
|
||||||
{
|
{
|
||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
|
|
||||||
private const string SOURCE_TENANT_QUERY_KEY = 'source_tenant_id';
|
private const string SOURCE_ENVIRONMENT_QUERY_KEY = 'source_environment_id';
|
||||||
|
|
||||||
private const string TARGET_TENANT_QUERY_KEY = 'target_tenant_id';
|
private const string TARGET_ENVIRONMENT_QUERY_KEY = 'target_environment_id';
|
||||||
|
|
||||||
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
|
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
|
||||||
|
|
||||||
@ -52,15 +60,15 @@ class CrossTenantComparePage extends Page implements HasForms
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
protected static ?string $title = 'Cross-Tenant Compare';
|
protected static ?string $title = 'Cross-environment compare';
|
||||||
|
|
||||||
protected static ?string $slug = 'cross-tenant-compare';
|
protected static ?string $slug = 'cross-environment-compare';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.cross-tenant-compare';
|
protected string $view = 'filament.pages.cross-environment-compare';
|
||||||
|
|
||||||
public ?string $sourceTenantId = null;
|
public ?string $sourceEnvironmentId = null;
|
||||||
|
|
||||||
public ?string $targetTenantId = null;
|
public ?string $targetEnvironmentId = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
@ -87,12 +95,12 @@ class CrossTenantComparePage extends Page implements HasForms
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, environment drill-downs, and one dominant promotion-preflight action.')
|
||||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-environment compare renders focused subject summaries instead of row-level overflow actions.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-environment compare is a workspace decision page, not a record detail header.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -116,24 +124,24 @@ public function form(Schema $schema): Schema
|
|||||||
'xl' => 3,
|
'xl' => 3,
|
||||||
])
|
])
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('sourceTenantId')
|
Select::make('sourceEnvironmentId')
|
||||||
->label('Source tenant')
|
->label('Source environment')
|
||||||
->options(fn (): array => $this->tenantOptions())
|
->options(fn (): array => $this->environmentOptions())
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->native(false)
|
->native(false)
|
||||||
->placeholder('Select a source tenant')
|
->placeholder('Select a source environment')
|
||||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source'])
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-environment-source'])
|
||||||
->extraInputAttributes(['data-testid' => 'cross-tenant-source']),
|
->extraInputAttributes(['data-testid' => 'cross-environment-source']),
|
||||||
Select::make('targetTenantId')
|
Select::make('targetEnvironmentId')
|
||||||
->label('Target tenant')
|
->label('Target environment')
|
||||||
->options(fn (): array => $this->tenantOptions())
|
->options(fn (): array => $this->environmentOptions())
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->native(false)
|
->native(false)
|
||||||
->placeholder('Select a target tenant')
|
->placeholder('Select a target environment')
|
||||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target'])
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-environment-target'])
|
||||||
->extraInputAttributes(['data-testid' => 'cross-tenant-target']),
|
->extraInputAttributes(['data-testid' => 'cross-environment-target']),
|
||||||
Select::make('selectedPolicyTypes')
|
Select::make('selectedPolicyTypes')
|
||||||
->label('Governed subjects')
|
->label('Governed subjects')
|
||||||
->options(fn (): array => $this->policyTypeOptions())
|
->options(fn (): array => $this->policyTypeOptions())
|
||||||
@ -143,10 +151,10 @@ public function form(Schema $schema): Schema
|
|||||||
->native(false)
|
->native(false)
|
||||||
->placeholder('All governed subjects')
|
->placeholder('All governed subjects')
|
||||||
->helperText(fn (): ?string => $this->policyTypeOptions() === []
|
->helperText(fn (): ?string => $this->policyTypeOptions() === []
|
||||||
? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.'
|
? 'Governed subject filters appear after authorized environment inventory exists in the active workspace.'
|
||||||
: null)
|
: null)
|
||||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types'])
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-environment-policy-types'])
|
||||||
->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']),
|
->extraInputAttributes(['data-testid' => 'cross-environment-policy-types']),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -168,30 +176,31 @@ protected function getHeaderActions(): array
|
|||||||
->url($navigationContext->backLinkUrl);
|
->url($navigationContext->backLinkUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceTenant = $this->selectedSourceTenant();
|
$sourceEnvironment = $this->selectedSourceEnvironment();
|
||||||
|
|
||||||
if ($sourceTenant instanceof Tenant) {
|
if ($sourceEnvironment instanceof ManagedEnvironment) {
|
||||||
$actions[] = Action::make('open_source_tenant')
|
$actions[] = Action::make('open_source_environment')
|
||||||
->label('Open source tenant')
|
->label('Open source environment')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
|
->url(ManagedEnvironmentLinks::viewUrl($sourceEnvironment));
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetTenant = $this->selectedTargetTenant();
|
$targetEnvironment = $this->selectedTargetEnvironment();
|
||||||
|
|
||||||
if ($targetTenant instanceof Tenant) {
|
if ($targetEnvironment instanceof ManagedEnvironment) {
|
||||||
$actions[] = Action::make('open_target_tenant')
|
$actions[] = Action::make('open_target_environment')
|
||||||
->label('Open target tenant')
|
->label('Open target environment')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
|
->url(ManagedEnvironmentLinks::viewUrl($targetEnvironment));
|
||||||
}
|
}
|
||||||
|
|
||||||
$preflightAction = Action::make('generatePromotionPreflight')
|
$preflightAction = Action::make('generatePromotionPreflight')
|
||||||
->label('Generate promotion preflight')
|
->label('Generate promotion preflight')
|
||||||
->icon('heroicon-o-sparkles')
|
->icon('heroicon-o-sparkles')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->visible(fn (): bool => ! is_array($this->preflight))
|
||||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||||
@ -201,6 +210,7 @@ protected function getHeaderActions(): array
|
|||||||
fn (): ?Workspace => $this->workspace(),
|
fn (): ?Workspace => $this->workspace(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
->preserveDisabled()
|
->preserveDisabled()
|
||||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||||
->apply()
|
->apply()
|
||||||
@ -223,6 +233,19 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$actions[] = $preflightAction;
|
$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;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,15 +254,15 @@ public function applySelection(): void
|
|||||||
$this->selectionMessage = null;
|
$this->selectionMessage = null;
|
||||||
$this->preflight = null;
|
$this->preflight = null;
|
||||||
|
|
||||||
$this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId);
|
$this->sourceEnvironmentId = $this->normalizeEnvironmentIdentifier($this->sourceEnvironmentId);
|
||||||
$this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId);
|
$this->targetEnvironmentId = $this->normalizeEnvironmentIdentifier($this->targetEnvironmentId);
|
||||||
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
|
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
|
||||||
|
|
||||||
if ($this->sourceTenantId !== null
|
if ($this->sourceEnvironmentId !== null
|
||||||
&& $this->targetTenantId !== null
|
&& $this->targetEnvironmentId !== null
|
||||||
&& $this->sourceTenantId === $this->targetTenantId) {
|
&& $this->sourceEnvironmentId === $this->targetEnvironmentId) {
|
||||||
$this->selectionMessage = 'Choose two different tenants.';
|
$this->selectionMessage = 'Choose two different environments.';
|
||||||
$this->addError('targetTenantId', $this->selectionMessage);
|
$this->addError('targetEnvironmentId', $this->selectionMessage);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -262,31 +285,99 @@ public function generatePromotionPreflight(): void
|
|||||||
|
|
||||||
$selection = $this->compareSelection();
|
$selection = $this->compareSelection();
|
||||||
|
|
||||||
if (! $selection instanceof CrossTenantCompareSelection) {
|
if (! $selection instanceof CrossEnvironmentCompareSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview);
|
$this->preflight = app(CrossEnvironmentPromotionPreflight::class)->build($this->preview);
|
||||||
|
|
||||||
$workspace = $this->workspace();
|
$workspace = $this->workspace();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if ($workspace instanceof Workspace && $user instanceof User) {
|
if ($workspace instanceof Workspace && $user instanceof User) {
|
||||||
app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated(
|
app(WorkspaceAuditLogger::class)->logCrossEnvironmentPromotionPreflightGenerated(
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
sourceTenant: $selection->sourceTenant,
|
sourceEnvironment: $selection->sourceEnvironment,
|
||||||
targetTenant: $selection->targetTenant,
|
targetEnvironment: $selection->targetEnvironment,
|
||||||
preflight: $this->preflight,
|
preflight: $this->preflight,
|
||||||
actor: $user,
|
actor: $user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 CrossEnvironmentCompareSelection || ! $user instanceof User) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Promotion execution unavailable')
|
||||||
|
->body('Refresh the compare selection before executing promotion.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = app(CrossEnvironmentPromotionExecutionService::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
|
public function clearSelectionUrl(): string
|
||||||
{
|
{
|
||||||
return static::getUrl($this->routeParameters([
|
return static::getUrl($this->routeParameters([
|
||||||
self::SOURCE_TENANT_QUERY_KEY => null,
|
self::SOURCE_ENVIRONMENT_QUERY_KEY => null,
|
||||||
self::TARGET_TENANT_QUERY_KEY => null,
|
self::TARGET_ENVIRONMENT_QUERY_KEY => null,
|
||||||
self::POLICY_TYPE_QUERY_KEY => null,
|
self::POLICY_TYPE_QUERY_KEY => null,
|
||||||
]), panel: 'admin');
|
]), panel: 'admin');
|
||||||
}
|
}
|
||||||
@ -297,18 +388,18 @@ public function selectionUrl(): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function launchUrl(
|
public static function launchUrl(
|
||||||
?Tenant $sourceTenant = null,
|
?ManagedEnvironment $sourceEnvironment = null,
|
||||||
?Tenant $targetTenant = null,
|
?ManagedEnvironment $targetEnvironment = null,
|
||||||
?CanonicalNavigationContext $navigationContext = null,
|
?CanonicalNavigationContext $navigationContext = null,
|
||||||
): string {
|
): string {
|
||||||
$parameters = [];
|
$parameters = [];
|
||||||
|
|
||||||
if ($sourceTenant instanceof Tenant) {
|
if ($sourceEnvironment instanceof ManagedEnvironment) {
|
||||||
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
|
$parameters[self::SOURCE_ENVIRONMENT_QUERY_KEY] = (int) $sourceEnvironment->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($targetTenant instanceof Tenant) {
|
if ($targetEnvironment instanceof ManagedEnvironment) {
|
||||||
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
|
$parameters[self::TARGET_ENVIRONMENT_QUERY_KEY] = (int) $targetEnvironment->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($navigationContext instanceof CanonicalNavigationContext) {
|
if ($navigationContext instanceof CanonicalNavigationContext) {
|
||||||
@ -320,8 +411,8 @@ public static function launchUrl(
|
|||||||
|
|
||||||
public function hasActiveSelection(): bool
|
public function hasActiveSelection(): bool
|
||||||
{
|
{
|
||||||
return $this->sourceTenantId !== null
|
return $this->sourceEnvironmentId !== null
|
||||||
|| $this->targetTenantId !== null
|
|| $this->targetEnvironmentId !== null
|
||||||
|| $this->selectedPolicyTypes !== [];
|
|| $this->selectedPolicyTypes !== [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,26 +438,26 @@ public function reasonLabel(string $reasonCode): string
|
|||||||
return Str::headline(str_replace('_', ' ', $reasonCode));
|
return Str::headline(str_replace('_', ' ', $reasonCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sourceTenantUrl(): ?string
|
public function sourceEnvironmentUrl(): ?string
|
||||||
{
|
{
|
||||||
$tenant = $this->selectedSourceTenant();
|
$environment = $this->selectedSourceEnvironment();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $environment instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
return ManagedEnvironmentLinks::viewUrl($environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function targetTenantUrl(): ?string
|
public function targetEnvironmentUrl(): ?string
|
||||||
{
|
{
|
||||||
$tenant = $this->selectedTargetTenant();
|
$environment = $this->selectedTargetEnvironment();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $environment instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
return ManagedEnvironmentLinks::viewUrl($environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -375,16 +466,16 @@ public function targetTenantUrl(): ?string
|
|||||||
private function formState(): array
|
private function formState(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'sourceTenantId' => $this->sourceTenantId,
|
'sourceEnvironmentId' => $this->sourceEnvironmentId,
|
||||||
'targetTenantId' => $this->targetTenantId,
|
'targetEnvironmentId' => $this->targetEnvironmentId,
|
||||||
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hydrateSelectionFromRequest(): void
|
private function hydrateSelectionFromRequest(): void
|
||||||
{
|
{
|
||||||
$this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY));
|
$this->sourceEnvironmentId = $this->normalizeEnvironmentIdentifier(request()->query(self::SOURCE_ENVIRONMENT_QUERY_KEY));
|
||||||
$this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY));
|
$this->targetEnvironmentId = $this->normalizeEnvironmentIdentifier(request()->query(self::TARGET_ENVIRONMENT_QUERY_KEY));
|
||||||
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
|
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,11 +487,11 @@ private function refreshPreview(): void
|
|||||||
|
|
||||||
$selection = $this->compareSelection();
|
$selection = $this->compareSelection();
|
||||||
|
|
||||||
if (! $selection instanceof CrossTenantCompareSelection) {
|
if (! $selection instanceof CrossEnvironmentCompareSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
|
$this->preview = app(CrossEnvironmentComparePreviewBuilder::class)->build($selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
private function authorizePageAccess(): void
|
||||||
@ -453,47 +544,71 @@ private function authorizePreflightExecution(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function compareSelection(): ?CrossTenantCompareSelection
|
private function authorizePromotionExecution(): void
|
||||||
{
|
{
|
||||||
$sourceTenant = $this->selectedSourceTenant();
|
$this->authorizePreflightExecution();
|
||||||
$targetTenant = $this->selectedTargetTenant();
|
|
||||||
|
|
||||||
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetEnvironment = $this->selectedTargetEnvironment();
|
||||||
|
|
||||||
|
if (! $targetEnvironment instanceof ManagedEnvironment) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $targetEnvironment, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareSelection(): ?CrossEnvironmentCompareSelection
|
||||||
|
{
|
||||||
|
$sourceEnvironment = $this->selectedSourceEnvironment();
|
||||||
|
$targetEnvironment = $this->selectedTargetEnvironment();
|
||||||
|
|
||||||
|
if (! $sourceEnvironment instanceof ManagedEnvironment || ! $targetEnvironment instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) {
|
if ((int) $sourceEnvironment->getKey() === (int) $targetEnvironment->getKey()) {
|
||||||
$this->selectionMessage = 'Choose two different tenants.';
|
$this->selectionMessage = 'Choose two different environments.';
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CrossTenantCompareSelection(
|
return new CrossEnvironmentCompareSelection(
|
||||||
sourceTenant: $sourceTenant,
|
sourceEnvironment: $sourceEnvironment,
|
||||||
targetTenant: $targetTenant,
|
targetEnvironment: $targetEnvironment,
|
||||||
policyTypes: $this->selectedPolicyTypes,
|
policyTypes: $this->selectedPolicyTypes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function selectedSourceTenant(): ?Tenant
|
private function selectedSourceEnvironment(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
if ($this->sourceTenantId === null) {
|
if ($this->sourceEnvironmentId === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveAuthorizedTenant($this->sourceTenantId);
|
return $this->resolveAuthorizedEnvironment($this->sourceEnvironmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function selectedTargetTenant(): ?Tenant
|
private function selectedTargetEnvironment(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
if ($this->targetTenantId === null) {
|
if ($this->targetEnvironmentId === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveAuthorizedTenant($this->targetTenantId);
|
return $this->resolveAuthorizedEnvironment($this->targetEnvironmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveAuthorizedTenant(string $tenantId): Tenant
|
private function resolveAuthorizedEnvironment(string $environmentId): ManagedEnvironment
|
||||||
{
|
{
|
||||||
$workspace = $this->workspace();
|
$workspace = $this->workspace();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -502,29 +617,29 @@ private function resolveAuthorizedTenant(string $tenantId): Tenant
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::query()
|
$environment = ManagedEnvironment::query()
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->whereKey((int) $tenantId)
|
->whereKey((int) $environmentId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $environment instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
if (! $user->canAccessTenant($environment) || ! $resolver->can($user, $environment, Capabilities::TENANT_VIEW)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $tenant;
|
return $environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private function tenantOptions(): array
|
private function environmentOptions(): array
|
||||||
{
|
{
|
||||||
$workspace = $this->workspace();
|
$workspace = $this->workspace();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -536,18 +651,17 @@ private function tenantOptions(): array
|
|||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
$tenants = $user->tenants()
|
$environments = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
->select('managed_environments.*')
|
||||||
->select('tenants.*')
|
->orderBy('managed_environments.name')
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$resolver->primeMemberships($user, $tenants->modelKeys());
|
$resolver->primeMemberships($user, $environments->modelKeys());
|
||||||
|
|
||||||
return $tenants
|
return $environments
|
||||||
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
->filter(fn (ManagedEnvironment $environment): bool => $resolver->can($user, $environment, Capabilities::TENANT_VIEW))
|
||||||
->mapWithKeys(fn (Tenant $tenant): array => [
|
->mapWithKeys(fn (ManagedEnvironment $environment): array => [
|
||||||
(string) $tenant->getKey() => (string) $tenant->name,
|
(string) $environment->getKey() => (string) $environment->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -557,14 +671,14 @@ private function tenantOptions(): array
|
|||||||
*/
|
*/
|
||||||
private function policyTypeOptions(): array
|
private function policyTypeOptions(): array
|
||||||
{
|
{
|
||||||
$tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions()));
|
$environmentIds = array_map(static fn (string $environmentId): int => (int) $environmentId, array_keys($this->environmentOptions()));
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
if ($environmentIds === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return InventoryItem::query()
|
return InventoryItem::query()
|
||||||
->whereIn('tenant_id', $tenantIds)
|
->whereIn('managed_environment_id', $environmentIds)
|
||||||
->whereNotNull('policy_type')
|
->whereNotNull('policy_type')
|
||||||
->where('policy_type', '!=', '')
|
->where('policy_type', '!=', '')
|
||||||
->distinct()
|
->distinct()
|
||||||
@ -583,7 +697,7 @@ private function preflightDisabledReason(): ?string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! is_array($this->preview)) {
|
if (! is_array($this->preview)) {
|
||||||
return 'Select an authorized source and target tenant to generate a promotion preflight.';
|
return 'Select an authorized source and target environment to generate a promotion preflight.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
|
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
|
||||||
@ -593,10 +707,77 @@ private function preflightDisabledReason(): ?string
|
|||||||
return null;
|
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.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetEnvironment = $this->selectedTargetEnvironment();
|
||||||
|
|
||||||
|
if ($targetEnvironment instanceof ManagedEnvironment) {
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $targetEnvironment, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return 'You need target environment 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;
|
||||||
|
|
||||||
|
$sourceEnvironmentName = $selection?->sourceEnvironment->name ?? 'Source environment';
|
||||||
|
$targetEnvironmentName = $selection?->targetEnvironment->name ?? 'Target environment';
|
||||||
|
|
||||||
|
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.',
|
||||||
|
$sourceEnvironmentName,
|
||||||
|
$targetEnvironmentName,
|
||||||
|
$ready,
|
||||||
|
$ready === 1 ? '' : 's',
|
||||||
|
$excluded,
|
||||||
|
$excluded === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
*/
|
*/
|
||||||
private function normalizeTenantIdentifier(mixed $value): ?string
|
private function normalizeEnvironmentIdentifier(mixed $value): ?string
|
||||||
{
|
{
|
||||||
if (! is_string($value) && ! is_int($value)) {
|
if (! is_string($value) && ! is_int($value)) {
|
||||||
return null;
|
return null;
|
||||||
@ -634,8 +815,8 @@ private function normalizePolicyTypes(mixed $value): array
|
|||||||
private function routeParameters(array $overrides = []): array
|
private function routeParameters(array $overrides = []): array
|
||||||
{
|
{
|
||||||
$parameters = [
|
$parameters = [
|
||||||
self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId,
|
self::SOURCE_ENVIRONMENT_QUERY_KEY => $this->sourceEnvironmentId,
|
||||||
self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId,
|
self::TARGET_ENVIRONMENT_QUERY_KEY => $this->targetEnvironmentId,
|
||||||
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
|
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -4,26 +4,27 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentTriageArrivalContinuity;
|
||||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
use App\Filament\Widgets\Dashboard\EnvironmentDashboardContextChips;
|
||||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
use App\Filament\Widgets\Dashboard\EnvironmentDashboardOverview;
|
||||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
|
||||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
|
||||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
|
||||||
use App\Models\SupportRequest;
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||||
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
|
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummary;
|
||||||
|
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Placeholder;
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@ -32,21 +33,57 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Support\Enums\Width;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Filament\Widgets\WidgetConfiguration;
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class TenantDashboard extends Dashboard
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
|
|
||||||
|
class EnvironmentDashboard extends Dashboard
|
||||||
{
|
{
|
||||||
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
public array $supportDiagnosticsAuditKeys = [];
|
public array $supportDiagnosticsAuditKeys = [];
|
||||||
|
|
||||||
public function getTitle(): string
|
private ?EnvironmentDashboardSummary $dashboardSummary = null;
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
return __('localization.dashboard.tenant_title');
|
return __('localization.dashboard.environment_title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string | Htmlable
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
|
return __('localization.dashboard.environment_title');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $this->dashboardSummary();
|
||||||
|
|
||||||
|
if (! $summary instanceof EnvironmentDashboardSummary) {
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,7 +91,34 @@ public function getTitle(): string
|
|||||||
*/
|
*/
|
||||||
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = array_diff_key($parameters, array_flip(['tenant', 'environment', 'workspace']));
|
||||||
|
|
||||||
|
return ManagedEnvironmentLinks::viewUrl($resolvedTenant, $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||||
|
*/
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EnvironmentDashboardContextChips::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaderWidgetsColumns(): int|array
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,19 +127,15 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
|
|||||||
public function getWidgets(): array
|
public function getWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TenantTriageArrivalContinuity::class,
|
ManagedEnvironmentTriageArrivalContinuity::class,
|
||||||
RecoveryReadiness::class,
|
|
||||||
DashboardKpis::class,
|
DashboardKpis::class,
|
||||||
NeedsAttention::class,
|
EnvironmentDashboardOverview::class,
|
||||||
BaselineCompareNow::class,
|
|
||||||
RecentDriftFindings::class,
|
|
||||||
RecentOperations::class,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getColumns(): int|array
|
public function getColumns(): int|array
|
||||||
{
|
{
|
||||||
return 2;
|
return ['default' => 1, 'xl' => 12];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -83,10 +143,193 @@ public function getColumns(): int|array
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
|
|
||||||
|
if ($primaryAction = $this->primaryFollowUpHeaderAction()) {
|
||||||
|
$actions[] = $primaryAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
$moreActions = array_values(array_filter([
|
||||||
|
$this->secondaryHeaderAction(),
|
||||||
$this->requestSupportAction(),
|
$this->requestSupportAction(),
|
||||||
$this->openSupportDiagnosticsAction(),
|
$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 EnvironmentDashboardSummary) {
|
||||||
|
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 EnvironmentDashboardSummary) {
|
||||||
|
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(): ?EnvironmentDashboardSummary
|
||||||
|
{
|
||||||
|
if ($this->dashboardSummary instanceof EnvironmentDashboardSummary) {
|
||||||
|
return $this->dashboardSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dashboardSummary = app(EnvironmentDashboardSummaryBuilder::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
|
public function authorizeTenantSupportRequest(): void
|
||||||
@ -244,7 +487,7 @@ private function auditTenantSupportDiagnosticsOpen(): void
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $bundle
|
* @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();
|
$auditKey = 'tenant:'.$tenant->getKey();
|
||||||
|
|
||||||
@ -285,12 +528,12 @@ private function resolveDashboardActor(): User
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveCurrentTenantForCapability(string $capability): Tenant
|
private function resolveCurrentTenantForCapability(string $capability): ManagedEnvironment
|
||||||
{
|
{
|
||||||
$user = $this->resolveDashboardActor();
|
$user = $this->resolveDashboardActor();
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,7 +555,7 @@ private function tenantSupportRequestAttachmentSummary(): string
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
$user = auth()->user();
|
$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.';
|
return 'Only canonical redacted tenant context will be attached.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5,10 +5,9 @@
|
|||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\TenantDiagnosticsService;
|
use App\Services\Auth\ManagedEnvironmentDiagnosticsService;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\ManagedEnvironmentMembershipManager;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
@ -18,7 +17,7 @@
|
|||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
class TenantDiagnostics extends Page
|
class EnvironmentDiagnostics extends Page
|
||||||
{
|
{
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
@ -26,13 +25,13 @@ class TenantDiagnostics extends Page
|
|||||||
|
|
||||||
protected static ?string $slug = 'diagnostics';
|
protected static ?string $slug = 'diagnostics';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.tenant-diagnostics';
|
protected string $view = 'filament.pages.environment-diagnostics';
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.')
|
->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::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk 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.');
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.');
|
||||||
@ -45,19 +44,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
$tenantId = (int) $tenant->getKey();
|
$this->missingOwner = app(ManagedEnvironmentDiagnosticsService::class)->tenantHasNoOwners($tenant);
|
||||||
|
|
||||||
$this->missingOwner = ! TenantMembership::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('role', 'owner')
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403, 'Not allowed');
|
abort(403, 'Not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->hasDuplicateMembershipsForCurrentUser = app(TenantDiagnosticsService::class)
|
$this->hasDuplicateMembershipsForCurrentUser = app(ManagedEnvironmentDiagnosticsService::class)
|
||||||
->userHasDuplicateMemberships($tenant, $user);
|
->userHasDuplicateMemberships($tenant, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +75,7 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('mergeDuplicateMemberships')
|
Action::make('mergeDuplicateMemberships')
|
||||||
->label('Merge duplicate memberships')
|
->label('Merge duplicate access scopes')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(fn () => $this->mergeDuplicateMemberships()),
|
->action(fn () => $this->mergeDuplicateMemberships()),
|
||||||
)
|
)
|
||||||
@ -102,7 +96,7 @@ public function bootstrapOwner(): void
|
|||||||
abort(403, 'Not allowed');
|
abort(403, 'Not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
|
app(ManagedEnvironmentMembershipManager::class)->grantScope($tenant, $user, $user, sourceRef: 'diagnostic');
|
||||||
|
|
||||||
$this->mount();
|
$this->mount();
|
||||||
}
|
}
|
||||||
@ -116,7 +110,7 @@ public function mergeDuplicateMemberships(): void
|
|||||||
abort(403, 'Not allowed');
|
abort(403, 'Not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
app(TenantDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
|
app(ManagedEnvironmentDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
|
||||||
|
|
||||||
$this->mount();
|
$this->mount();
|
||||||
}
|
}
|
||||||
@ -4,15 +4,14 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
@ -30,7 +29,7 @@
|
|||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class TenantRequiredPermissions extends Page implements HasTable
|
class EnvironmentRequiredPermissions extends Page implements HasTable
|
||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
@ -38,11 +37,11 @@ class TenantRequiredPermissions extends Page implements HasTable
|
|||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
protected static ?string $slug = 'workspaces/{workspace}/environments/{environment}/required-permissions';
|
||||||
|
|
||||||
protected static ?string $title = 'Required permissions';
|
protected static ?string $title = 'Required permissions';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.tenant-required-permissions';
|
protected string $view = 'filament.pages.environment-required-permissions';
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
@ -69,16 +68,16 @@ public static function canAccess(): bool
|
|||||||
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function currentTenant(): ?Tenant
|
public function currentTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
return $this->trustedScopedTenant();
|
return $this->trustedScopedTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(Tenant|string|null $tenant = null): void
|
public function mount(ManagedEnvironment|string|null $environment = null): void
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant($tenant);
|
$tenant = static::resolveScopedTenant($environment);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
if (! $tenant instanceof ManagedEnvironment || ! static::hasScopedTenantAccess($tenant)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,10 +151,10 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ManagedEnvironmentPermissionStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
|
->color(BadgeRenderer::color(BadgeDomain::ManagedEnvironmentPermissionStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ManagedEnvironmentPermissionStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ManagedEnvironmentPermissionStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('features_label')
|
TextColumn::make('features_label')
|
||||||
->label('Features')
|
->label('Features')
|
||||||
@ -206,8 +205,8 @@ public function reRunVerificationUrl(): string
|
|||||||
{
|
{
|
||||||
$tenant = $this->trustedScopedTenant();
|
$tenant = $this->trustedScopedTenant();
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
return ManagedEnvironmentLinks::viewUrl($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return route('admin.onboarding');
|
return route('admin.onboarding');
|
||||||
@ -217,53 +216,53 @@ public function manageProviderConnectionUrl(): ?string
|
|||||||
{
|
{
|
||||||
$tenant = $this->trustedScopedTenant();
|
$tenant = $this->trustedScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
return ManagedEnvironmentLinks::providerConnectionsUrl($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_string($tenant) && $tenant !== '') {
|
if (is_string($tenant) && $tenant !== '') {
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('external_id', $tenant)
|
->where('slug', $tenant)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
$routeTenant = request()->route('tenant');
|
$routeTenant = request()->route('environment') ?? request()->route('tenant');
|
||||||
|
|
||||||
if ($routeTenant instanceof Tenant) {
|
if ($routeTenant instanceof ManagedEnvironment) {
|
||||||
return $routeTenant;
|
return $routeTenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
if (is_string($routeTenant) && $routeTenant !== '') {
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('external_id', $routeTenant)
|
->where('slug', $routeTenant)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
$queryTenant = request()->query('tenant');
|
$queryTenant = request()->query('tenant');
|
||||||
|
|
||||||
if (is_string($queryTenant) && $queryTenant !== '') {
|
if (is_string($queryTenant) && $queryTenant !== '') {
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('external_id', $queryTenant)
|
->where('slug', $queryTenant)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
private static function hasScopedTenantAccess(?ManagedEnvironment $tenant): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,7 +284,7 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
|||||||
return $user->canAccessTenant($tenant);
|
return $user->canAccessTenant($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function trustedScopedTenant(): ?Tenant
|
private function trustedScopedTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -303,7 +302,7 @@ private function trustedScopedTenant(): ?Tenant
|
|||||||
|
|
||||||
$routeTenant = static::resolveScopedTenant();
|
$routeTenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if ($routeTenant instanceof Tenant) {
|
if ($routeTenant instanceof ManagedEnvironment) {
|
||||||
try {
|
try {
|
||||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
||||||
} catch (NotFoundHttpException) {
|
} catch (NotFoundHttpException) {
|
||||||
@ -315,9 +314,9 @@ private function trustedScopedTenant(): ?Tenant
|
|||||||
return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,7 +333,7 @@ private function trustedScopedTenant(): ?Tenant
|
|||||||
*/
|
*/
|
||||||
private function filterState(array $filters = [], ?string $search = null): array
|
private function filterState(array $filters = [], ?string $search = null): array
|
||||||
{
|
{
|
||||||
return TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
return ManagedEnvironmentRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||||
'status' => $filters['status']['value'] ?? data_get($this->tableFilters, 'status.value'),
|
'status' => $filters['status']['value'] ?? data_get($this->tableFilters, 'status.value'),
|
||||||
'type' => $filters['type']['value'] ?? data_get($this->tableFilters, 'type.value'),
|
'type' => $filters['type']['value'] ?? data_get($this->tableFilters, 'type.value'),
|
||||||
'features' => $filters['features']['values'] ?? data_get($this->tableFilters, 'features.values', []),
|
'features' => $filters['features']['values'] ?? data_get($this->tableFilters, 'features.values', []),
|
||||||
@ -350,7 +349,7 @@ private function viewModelForState(array $state): array
|
|||||||
{
|
{
|
||||||
$tenant = $this->trustedScopedTenant();
|
$tenant = $this->trustedScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -360,7 +359,7 @@ private function viewModelForState(array $state): array
|
|||||||
return $this->cachedViewModel;
|
return $this->cachedViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
$builder = app(ManagedEnvironmentRequiredPermissionsViewModelBuilder::class);
|
||||||
|
|
||||||
$this->cachedViewModelStateKey = $stateKey ?: null;
|
$this->cachedViewModelStateKey = $stateKey ?: null;
|
||||||
$this->cachedViewModel = $builder->build($tenant, $state);
|
$this->cachedViewModel = $builder->build($tenant, $state);
|
||||||
@ -515,7 +514,7 @@ private function seedTableStateFromQuery(): void
|
|||||||
|
|
||||||
$queryFeatures = request()->query('features', []);
|
$queryFeatures = request()->query('features', []);
|
||||||
|
|
||||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
$state = ManagedEnvironmentRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||||
'status' => request()->query('status', 'missing'),
|
'status' => request()->query('status', 'missing'),
|
||||||
'type' => request()->query('type', 'all'),
|
'type' => request()->query('type', 'all'),
|
||||||
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
@ -54,7 +54,7 @@ class FindingsHygieneReport extends Page implements HasTable
|
|||||||
protected string $view = 'filament.pages.findings.findings-hygiene-report';
|
protected string $view = 'filament.pages.findings.findings-hygiene-report';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $visibleTenants = null;
|
private ?array $visibleTenants = null;
|
||||||
|
|
||||||
@ -65,12 +65,12 @@ class FindingsHygieneReport extends Page implements HasTable
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep the hygiene scope fixed and expose only fixed reason views plus tenant-prefilter recovery when needed.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep the hygiene scope fixed and expose only fixed reason views plus environment-prefilter recovery when needed.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The hygiene report stays read-only and exposes row click as the only inspect path.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The hygiene report stays read-only and exposes row click as the only inspect path.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The hygiene report does not expose bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The hygiene report does not expose bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm and only offers a tenant-prefilter reset when the active tenant filter hides otherwise visible issues.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm and only offers an environment-prefilter reset when the active environment filter hides otherwise visible issues.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Repair remains on the existing tenant finding detail surface.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Repair remains on the existing environment finding detail surface.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -93,7 +93,7 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('clear_tenant_filter')
|
Action::make('clear_tenant_filter')
|
||||||
->label('Clear tenant filter')
|
->label('Clear environment filter')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||||
@ -109,7 +109,7 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label('Tenant'),
|
->label('ManagedEnvironment'),
|
||||||
TextColumn::make('subject_display_name')
|
TextColumn::make('subject_display_name')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
->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))),
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($this->hygieneService()->lastWorkflowActivityAt($record))),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
@ -183,10 +183,10 @@ public function availableFilters(): array
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'tenant',
|
'key' => 'tenant',
|
||||||
'label' => 'Tenant',
|
'label' => 'ManagedEnvironment',
|
||||||
'fixed' => false,
|
'fixed' => false,
|
||||||
'options' => collect($this->visibleTenants())
|
'options' => collect($this->visibleTenants())
|
||||||
->map(fn (Tenant $tenant): array => [
|
->map(fn (ManagedEnvironment $tenant): array => [
|
||||||
'value' => (string) $tenant->getKey(),
|
'value' => (string) $tenant->getKey(),
|
||||||
'label' => (string) $tenant->name,
|
'label' => (string) $tenant->name,
|
||||||
])
|
])
|
||||||
@ -259,11 +259,11 @@ public function emptyState(): array
|
|||||||
{
|
{
|
||||||
if ($this->tenantFilterAloneExcludesRows()) {
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
return [
|
return [
|
||||||
'title' => 'No hygiene issues match this tenant scope',
|
'title' => 'No hygiene issues match this environment scope',
|
||||||
'body' => 'Your current tenant filter is hiding hygiene issues that are still visible elsewhere in this workspace.',
|
'body' => 'Your current environment filter is hiding hygiene issues that are still visible elsewhere in this workspace.',
|
||||||
'icon' => 'heroicon-o-funnel',
|
'icon' => 'heroicon-o-funnel',
|
||||||
'action_name' => 'clear_tenant_filter_empty',
|
'action_name' => 'clear_tenant_filter_empty',
|
||||||
'action_label' => 'Clear tenant filter',
|
'action_label' => 'Clear environment filter',
|
||||||
'action_kind' => 'clear_tenant_filter',
|
'action_kind' => 'clear_tenant_filter',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -278,7 +278,7 @@ public function emptyState(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => 'No visible hygiene issues right now',
|
'title' => 'No visible hygiene issues right now',
|
||||||
'body' => 'Visible broken assignments and stale in-progress work are currently calm across the entitled tenant scope.',
|
'body' => 'Visible broken assignments and stale in-progress work are currently calm across the entitled environment scope.',
|
||||||
'icon' => 'heroicon-o-wrench-screwdriver',
|
'icon' => 'heroicon-o-wrench-screwdriver',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -290,12 +290,12 @@ public function updatedTableFilters(): void
|
|||||||
|
|
||||||
public function clearTenantFilter(): void
|
public function clearTenantFilter(): void
|
||||||
{
|
{
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function visibleTenants(): array
|
public function visibleTenants(): array
|
||||||
{
|
{
|
||||||
@ -394,7 +394,7 @@ private function filteredIssueQuery(bool $includeTenantFilter = true, ?string $r
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return collect($this->visibleTenants())
|
return collect($this->visibleTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => (string) $tenant->name,
|
(string) $tenant->getKey() => (string) $tenant->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -413,8 +413,8 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -422,7 +422,7 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
|
|
||||||
private function normalizeTenantFilterState(): 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 === '') {
|
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
||||||
return;
|
return;
|
||||||
@ -432,7 +432,7 @@ private function normalizeTenantFilterState(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -450,7 +450,7 @@ private function currentFiltersState(): array
|
|||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
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)) {
|
if (! is_numeric($tenantFilter)) {
|
||||||
return null;
|
return null;
|
||||||
@ -467,7 +467,7 @@ private function currentTenantFilterId(): ?int
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
private function filteredTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenantId = $this->currentTenantFilterId();
|
$tenantId = $this->currentTenantFilterId();
|
||||||
|
|
||||||
@ -484,11 +484,11 @@ private function filteredTenant(): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function activeVisibleTenant(): ?Tenant
|
private function activeVisibleTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,13 +505,13 @@ private function tenantPrefilterSource(): string
|
|||||||
{
|
{
|
||||||
$tenant = $this->filteredTenant();
|
$tenant = $this->filteredTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeTenant = $this->activeVisibleTenant();
|
$activeTenant = $this->activeVisibleTenant();
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
if ($activeTenant instanceof ManagedEnvironment && $activeTenant->is($tenant)) {
|
||||||
return 'active_tenant_context';
|
return 'active_tenant_context';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -528,7 +528,7 @@ private function assigneeContext(Finding $record): ?string
|
|||||||
return 'Soft-deleted user';
|
return 'Soft-deleted user';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'No current tenant membership';
|
return 'No current environment access';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function tenantFilterAloneExcludesRows(): bool
|
private function tenantFilterAloneExcludesRows(): bool
|
||||||
@ -561,11 +561,11 @@ private function findingDetailUrl(Finding $record): string
|
|||||||
{
|
{
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return '#';
|
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());
|
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
@ -62,12 +62,12 @@ class FindingsIntakeQueue extends Page implements HasTable
|
|||||||
protected string $view = 'filament.pages.findings.findings-intake-queue';
|
protected string $view = 'filament.pages.findings.findings-intake-queue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $authorizedTenants = null;
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $visibleTenants = null;
|
private ?array $visibleTenants = null;
|
||||||
|
|
||||||
@ -118,7 +118,7 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$actions[] = Action::make('clear_tenant_filter')
|
$actions[] = Action::make('clear_tenant_filter')
|
||||||
->label('Clear tenant filter')
|
->label('Clear environment filter')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||||
@ -135,7 +135,7 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label('Tenant'),
|
->label('ManagedEnvironment'),
|
||||||
TextColumn::make('subject_display_name')
|
TextColumn::make('subject_display_name')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
->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)),
|
->color(fn (Finding $record): string => $this->queueReasonColor($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
@ -251,18 +251,18 @@ public function emptyState(): array
|
|||||||
{
|
{
|
||||||
if ($this->tenantFilterAloneExcludesRows()) {
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
return [
|
return [
|
||||||
'title' => 'No intake findings match this tenant scope',
|
'title' => 'No intake findings match this environment scope',
|
||||||
'body' => 'Your current tenant filter is hiding shared intake work that is still visible elsewhere in this workspace.',
|
'body' => 'Your current environment filter is hiding shared intake work that is still visible elsewhere in this workspace.',
|
||||||
'icon' => 'heroicon-o-funnel',
|
'icon' => 'heroicon-o-funnel',
|
||||||
'action_name' => 'clear_tenant_filter_empty',
|
'action_name' => 'clear_tenant_filter_empty',
|
||||||
'action_label' => 'Clear tenant filter',
|
'action_label' => 'Clear environment filter',
|
||||||
'action_kind' => 'clear_tenant_filter',
|
'action_kind' => 'clear_tenant_filter',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => 'Shared intake is clear',
|
'title' => 'Shared intake is clear',
|
||||||
'body' => 'No visible unassigned findings currently need first routing across your entitled tenants. Open your personal queue if you want to continue with claimed work.',
|
'body' => 'No visible unassigned findings currently need first routing across your entitled environments. Open your personal queue if you want to continue with claimed work.',
|
||||||
'icon' => 'heroicon-o-inbox-stack',
|
'icon' => 'heroicon-o-inbox-stack',
|
||||||
'action_name' => 'open_my_findings_empty',
|
'action_name' => 'open_my_findings_empty',
|
||||||
'action_label' => 'Open my findings',
|
'action_label' => 'Open my findings',
|
||||||
@ -278,12 +278,12 @@ public function updatedTableFilters(): void
|
|||||||
|
|
||||||
public function clearTenantFilter(): void
|
public function clearTenantFilter(): void
|
||||||
{
|
{
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function visibleTenants(): array
|
public function visibleTenants(): array
|
||||||
{
|
{
|
||||||
@ -301,12 +301,12 @@ public function visibleTenants(): array
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
$resolver->primeMemberships(
|
$resolver->primeMemberships(
|
||||||
$user,
|
$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(
|
return $this->visibleTenants = array_values(array_filter(
|
||||||
$tenants,
|
$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;
|
$tenant = $record->tenant;
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,7 +406,7 @@ private function authorizePageAccess(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
private function authorizedTenants(): array
|
private function authorizedTenants(): array
|
||||||
{
|
{
|
||||||
@ -421,11 +421,10 @@ private function authorizedTenants(): array
|
|||||||
return $this->authorizedTenants = [];
|
return $this->authorizedTenants = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authorizedTenants = $user->tenants()
|
return $this->authorizedTenants = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
->where('managed_environments.lifecycle_status', 'active')
|
||||||
->where('tenants.status', 'active')
|
->orderBy('managed_environments.name')
|
||||||
->orderBy('tenants.name')
|
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -448,7 +447,7 @@ private function queueBaseQuery(): Builder
|
|||||||
{
|
{
|
||||||
$workspace = $this->workspace();
|
$workspace = $this->workspace();
|
||||||
$tenantIds = array_map(
|
$tenantIds = array_map(
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||||
$this->visibleTenants(),
|
$this->visibleTenants(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -460,7 +459,7 @@ private function queueBaseQuery(): Builder
|
|||||||
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
||||||
->withSubjectDisplayName()
|
->withSubjectDisplayName()
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||||
->whereNull('assignee_user_id')
|
->whereNull('assignee_user_id')
|
||||||
->whereIn('status', Finding::openStatuses());
|
->whereIn('status', Finding::openStatuses());
|
||||||
}
|
}
|
||||||
@ -479,7 +478,7 @@ private function filteredQueueQuery(
|
|||||||
$resolvedQueueView = $queueView ?? $this->queueView;
|
$resolvedQueueView = $queueView ?? $this->queueView;
|
||||||
|
|
||||||
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterId()) !== null) {
|
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterId()) !== null) {
|
||||||
$query->where('tenant_id', $tenantId);
|
$query->where('managed_environment_id', $tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($resolvedQueueView === 'needs_triage') {
|
if ($resolvedQueueView === 'needs_triage') {
|
||||||
@ -514,7 +513,7 @@ private function filteredQueueQuery(
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return collect($this->visibleTenants())
|
return collect($this->visibleTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => (string) $tenant->name,
|
(string) $tenant->getKey() => (string) $tenant->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -533,8 +532,8 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -542,7 +541,7 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
|
|
||||||
private function normalizeTenantFilterState(): 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 === '') {
|
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
||||||
return;
|
return;
|
||||||
@ -552,7 +551,7 @@ private function normalizeTenantFilterState(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -570,7 +569,7 @@ private function currentQueueFiltersState(): array
|
|||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
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)) {
|
if (! is_numeric($tenantFilter)) {
|
||||||
return null;
|
return null;
|
||||||
@ -587,7 +586,7 @@ private function currentTenantFilterId(): ?int
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
private function filteredTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenantId = $this->currentTenantFilterId();
|
$tenantId = $this->currentTenantFilterId();
|
||||||
|
|
||||||
@ -604,11 +603,11 @@ private function filteredTenant(): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function activeVisibleTenant(): ?Tenant
|
private function activeVisibleTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -625,13 +624,13 @@ private function tenantPrefilterSource(): string
|
|||||||
{
|
{
|
||||||
$tenant = $this->filteredTenant();
|
$tenant = $this->filteredTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeTenant = $this->activeVisibleTenant();
|
$activeTenant = $this->activeVisibleTenant();
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
if ($activeTenant instanceof ManagedEnvironment && $activeTenant->is($tenant)) {
|
||||||
return 'active_tenant_context';
|
return 'active_tenant_context';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -690,11 +689,11 @@ private function findingDetailUrl(Finding $record): string
|
|||||||
{
|
{
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return '#';
|
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());
|
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
@ -58,12 +58,12 @@ class MyFindingsInbox extends Page implements HasTable
|
|||||||
protected string $view = 'filament.pages.findings.my-findings-inbox';
|
protected string $view = 'filament.pages.findings.my-findings-inbox';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $authorizedTenants = null;
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $visibleTenants = null;
|
private ?array $visibleTenants = null;
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ class MyFindingsInbox extends Page implements HasTable
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only an environment-prefilter clear action when needed.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The personal findings inbox exposes row click as the only inspect path and does not render a secondary More menu.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The personal findings inbox exposes row click as the only inspect path and does not render a secondary More menu.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
|
||||||
@ -110,7 +110,7 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$actions[] = Action::make('clear_tenant_filter')
|
$actions[] = Action::make('clear_tenant_filter')
|
||||||
->label('Clear tenant filter')
|
->label('Clear environment filter')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||||
@ -127,7 +127,7 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label('Tenant'),
|
->label('Managed environment'),
|
||||||
TextColumn::make('subject_display_name')
|
TextColumn::make('subject_display_name')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
->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)),
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('Managed environment')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Filter::make('overdue')
|
Filter::make('overdue')
|
||||||
@ -207,10 +207,10 @@ public function availableFilters(): array
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'tenant',
|
'key' => 'tenant',
|
||||||
'label' => 'Tenant',
|
'label' => 'Managed environment',
|
||||||
'fixed' => false,
|
'fixed' => false,
|
||||||
'options' => collect($this->visibleTenants())
|
'options' => collect($this->visibleTenants())
|
||||||
->map(fn (Tenant $tenant): array => [
|
->map(fn (ManagedEnvironment $tenant): array => [
|
||||||
'value' => (string) $tenant->getKey(),
|
'value' => (string) $tenant->getKey(),
|
||||||
'label' => (string) $tenant->name,
|
'label' => (string) $tenant->name,
|
||||||
])
|
])
|
||||||
@ -261,37 +261,37 @@ public function emptyState(): array
|
|||||||
{
|
{
|
||||||
if ($this->tenantFilterAloneExcludesRows()) {
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
return [
|
return [
|
||||||
'title' => 'No assigned findings match this tenant scope',
|
'title' => 'No assigned findings match this environment scope',
|
||||||
'body' => 'Your current tenant filter is hiding assigned work that is still visible elsewhere in this workspace.',
|
'body' => 'Your current environment filter is hiding assigned work that is still visible elsewhere in this workspace.',
|
||||||
'icon' => 'heroicon-o-funnel',
|
'icon' => 'heroicon-o-funnel',
|
||||||
'action_name' => 'clear_tenant_filter_empty',
|
'action_name' => 'clear_tenant_filter_empty',
|
||||||
'action_label' => 'Clear tenant filter',
|
'action_label' => 'Clear environment filter',
|
||||||
'action_kind' => 'clear_tenant_filter',
|
'action_kind' => 'clear_tenant_filter',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeTenant = $this->activeVisibleTenant();
|
$activeTenant = $this->activeVisibleTenant();
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
if ($activeTenant instanceof ManagedEnvironment) {
|
||||||
return [
|
return [
|
||||||
'title' => 'No visible assigned findings right now',
|
'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.',
|
'body' => 'Nothing currently assigned to you needs attention in the visible environment scope. You can still open environment findings for broader context.',
|
||||||
'icon' => 'heroicon-o-clipboard-document-check',
|
'icon' => 'heroicon-o-clipboard-document-check',
|
||||||
'action_name' => 'open_tenant_findings_empty',
|
'action_name' => 'open_tenant_findings_empty',
|
||||||
'action_label' => 'Open tenant findings',
|
'action_label' => 'Open environment findings',
|
||||||
'action_kind' => 'url',
|
'action_kind' => 'url',
|
||||||
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant),
|
'action_url' => FindingResource::getUrl('index', tenant: $activeTenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => 'No visible assigned findings right now',
|
'title' => 'No visible assigned findings right now',
|
||||||
'body' => 'Nothing currently assigned to you needs attention across the visible tenant scope. Choose a tenant to continue working elsewhere in the workspace.',
|
'body' => 'Nothing currently assigned to you needs attention across the visible environment scope. Choose an environment to continue working elsewhere in the workspace.',
|
||||||
'icon' => 'heroicon-o-clipboard-document-check',
|
'icon' => 'heroicon-o-clipboard-document-check',
|
||||||
'action_name' => 'choose_tenant_empty',
|
'action_name' => 'choose_environment_empty',
|
||||||
'action_label' => 'Choose a tenant',
|
'action_label' => 'Choose an environment',
|
||||||
'action_kind' => 'url',
|
'action_kind' => 'url',
|
||||||
'action_url' => route('filament.admin.pages.choose-tenant'),
|
'action_url' => route('filament.admin.pages.choose-environment'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,12 +302,12 @@ public function updatedTableFilters(): void
|
|||||||
|
|
||||||
public function clearTenantFilter(): void
|
public function clearTenantFilter(): void
|
||||||
{
|
{
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function visibleTenants(): array
|
public function visibleTenants(): array
|
||||||
{
|
{
|
||||||
@ -325,12 +325,12 @@ public function visibleTenants(): array
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
$resolver->primeMemberships(
|
$resolver->primeMemberships(
|
||||||
$user,
|
$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(
|
return $this->visibleTenants = array_values(array_filter(
|
||||||
$tenants,
|
$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
|
private function authorizedTenants(): array
|
||||||
{
|
{
|
||||||
@ -370,11 +370,10 @@ private function authorizedTenants(): array
|
|||||||
return $this->authorizedTenants = [];
|
return $this->authorizedTenants = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authorizedTenants = $user->tenants()
|
return $this->authorizedTenants = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
->where('managed_environments.lifecycle_status', 'active')
|
||||||
->where('tenants.status', 'active')
|
->orderBy('managed_environments.name')
|
||||||
->orderBy('tenants.name')
|
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -398,7 +397,7 @@ private function queueBaseQuery(): Builder
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$workspace = $this->workspace();
|
$workspace = $this->workspace();
|
||||||
$tenantIds = array_map(
|
$tenantIds = array_map(
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||||
$this->visibleTenants(),
|
$this->visibleTenants(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -410,7 +409,7 @@ private function queueBaseQuery(): Builder
|
|||||||
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
||||||
->withSubjectDisplayName()
|
->withSubjectDisplayName()
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
->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())
|
->where('assignee_user_id', (int) $user->getKey())
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
->orderByRaw(
|
->orderByRaw(
|
||||||
@ -428,7 +427,7 @@ private function filteredQueueQuery(bool $includeTenantFilter = true): Builder
|
|||||||
$filters = $this->currentQueueFiltersState();
|
$filters = $this->currentQueueFiltersState();
|
||||||
|
|
||||||
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterIdFromFilters($filters)) !== null) {
|
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterIdFromFilters($filters)) !== null) {
|
||||||
$query->where('tenant_id', $tenantId);
|
$query->where('managed_environment_id', $tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->filterIsActive($filters, 'overdue')) {
|
if ($this->filterIsActive($filters, 'overdue')) {
|
||||||
@ -454,7 +453,7 @@ private function filteredQueueQuery(bool $includeTenantFilter = true): Builder
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return collect($this->visibleTenants())
|
return collect($this->visibleTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => (string) $tenant->name,
|
(string) $tenant->getKey() => (string) $tenant->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -473,8 +472,8 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -482,7 +481,7 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
|
|
||||||
private function normalizeTenantFilterState(): 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 === '') {
|
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
||||||
return;
|
return;
|
||||||
@ -492,7 +491,7 @@ private function normalizeTenantFilterState(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -518,7 +517,7 @@ private function currentTenantFilterId(): ?int
|
|||||||
*/
|
*/
|
||||||
private function currentTenantFilterIdFromFilters(array $filters): ?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)) {
|
if (! is_numeric($tenantFilter)) {
|
||||||
return null;
|
return null;
|
||||||
@ -543,7 +542,7 @@ private function filterIsActive(array $filters, string $name): bool
|
|||||||
return (bool) data_get($filters, "{$name}.isActive", false);
|
return (bool) data_get($filters, "{$name}.isActive", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
private function filteredTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenantId = $this->currentTenantFilterId();
|
$tenantId = $this->currentTenantFilterId();
|
||||||
|
|
||||||
@ -560,11 +559,11 @@ private function filteredTenant(): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function activeVisibleTenant(): ?Tenant
|
private function activeVisibleTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -581,13 +580,13 @@ private function tenantPrefilterSource(): string
|
|||||||
{
|
{
|
||||||
$tenant = $this->filteredTenant();
|
$tenant = $this->filteredTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeTenant = $this->activeVisibleTenant();
|
$activeTenant = $this->activeVisibleTenant();
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
if ($activeTenant instanceof ManagedEnvironment && $activeTenant->is($tenant)) {
|
||||||
return 'active_tenant_context';
|
return 'active_tenant_context';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -632,11 +631,11 @@ private function findingDetailUrl(Finding $record): string
|
|||||||
{
|
{
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return '#';
|
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());
|
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
||||||
}
|
}
|
||||||
|
|||||||
751
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
751
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
@ -0,0 +1,751 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||||
|
workspace: $workspace,
|
||||||
|
visibleTenants: $visibleTenants,
|
||||||
|
registerState: 'open',
|
||||||
|
)['counts'] ?? [];
|
||||||
|
|
||||||
|
return (int) ($counts['open'] ?? 0) > 0
|
||||||
|
|| (int) ($counts['recently_closed'] ?? 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 environment 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 environment scope is calm, but other visible environments 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 environment scope if you were filtering the register.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Try widening the environment scope or switch to recently closed decisions if you are checking what was just finished.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emptyStateActionLabel(): ?string
|
||||||
|
{
|
||||||
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
|
return 'Clear environment 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(fn (FindingException $record): string => (string) ($this->rowPayload($record)['proof_label'] ?? 'No linked proof'))
|
||||||
|
->description(fn (FindingException $record): ?string => $this->proofUrl($record) !== null
|
||||||
|
? (string) ($this->rowPayload($record)['proof_url_label'] ?? 'View proof')
|
||||||
|
: null)
|
||||||
|
->url(fn (FindingException $record): ?string => $this->proofUrl($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('operation_run_link')
|
||||||
|
->label('Operation')
|
||||||
|
->state(fn (FindingException $record): string => (string) ($this->rowPayload($record)['operation_run_label'] ?? 'No operation linked'))
|
||||||
|
->url(fn (FindingException $record): ?string => $this->operationRunUrl($record))
|
||||||
|
->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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $this->unfilteredRegisterPayload()['counts'] ?? [];
|
||||||
|
|
||||||
|
if ((int) ($counts['open'] ?? 0) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($counts['recently_closed'] ?? 0) > 0) {
|
||||||
|
$this->redirect($this->pageUrl(['register_state' => 'recently_closed']), navigate: true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($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->accessibleManagedEnvironmentsQuery((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 proofUrl(FindingException $record): ?string
|
||||||
|
{
|
||||||
|
$row = $this->rowPayload($record);
|
||||||
|
$proofState = $row['proof_state'] ?? null;
|
||||||
|
|
||||||
|
if ($proofState === 'linked_detail_section') {
|
||||||
|
return $this->decisionUrl($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $row['proof_url'] ?? null;
|
||||||
|
|
||||||
|
return is_string($url) && $url !== '' ? $url : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operationRunUrl(FindingException $record): ?string
|
||||||
|
{
|
||||||
|
$url = $this->rowPayload($record)['operation_run_url'] ?? null;
|
||||||
|
|
||||||
|
return is_string($url) && $url !== '' ? $url : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,12 +6,12 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
|
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
@ -47,17 +47,17 @@ class GovernanceInbox extends Page
|
|||||||
protected string $view = 'filament.pages.governance.governance-inbox';
|
protected string $view = 'filament.pages.governance.governance-inbox';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $authorizedTenants = null;
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $visibleFindingTenants = null;
|
private ?array $visibleFindingTenants = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $reviewTenants = null;
|
private ?array $reviewTenants = null;
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ public function appliedScope(): array
|
|||||||
return [
|
return [
|
||||||
'workspace_label' => $this->workspace()?->name,
|
'workspace_label' => $this->workspace()?->name,
|
||||||
'tenant_label' => $selectedTenant?->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_key' => $this->family,
|
||||||
'family_label' => $this->family !== null
|
'family_label' => $this->family !== null
|
||||||
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
|
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
|
||||||
@ -145,9 +145,9 @@ public function calmEmptyState(): array
|
|||||||
{
|
{
|
||||||
if ($this->tenantFilterAloneExcludesRows()) {
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
return [
|
return [
|
||||||
'title' => 'This tenant filter is hiding other visible attention',
|
'title' => 'This environment filter is hiding other visible attention',
|
||||||
'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.',
|
'body' => 'The current environment scope is calm, but other visible environments in this workspace still have governance attention.',
|
||||||
'action_label' => 'Clear tenant filter',
|
'action_label' => 'Clear environment filter',
|
||||||
'action_url' => $this->pageUrl(['tenant' => null]),
|
'action_url' => $this->pageUrl(['tenant' => null]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -162,7 +162,7 @@ public function calmEmptyState(): array
|
|||||||
|
|
||||||
public function hasTenantPrefilter(): bool
|
public function hasTenantPrefilter(): bool
|
||||||
{
|
{
|
||||||
return $this->selectedTenant() instanceof Tenant;
|
return $this->selectedTenant() instanceof ManagedEnvironment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isActiveFamily(?string $familyKey): bool
|
public function isActiveFamily(?string $familyKey): bool
|
||||||
@ -183,7 +183,7 @@ public function pageUrl(array $overrides = []): string
|
|||||||
return static::getUrl(
|
return static::getUrl(
|
||||||
panel: 'admin',
|
panel: 'admin',
|
||||||
parameters: array_filter([
|
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,
|
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
], 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
|
private function visibleFindingTenants(): array
|
||||||
{
|
{
|
||||||
@ -308,17 +308,17 @@ private function visibleFindingTenants(): array
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
$resolver->primeMemberships(
|
$resolver->primeMemberships(
|
||||||
$user,
|
$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(
|
return $this->visibleFindingTenants = array_values(array_filter(
|
||||||
$tenants,
|
$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
|
private function reviewTenants(): array
|
||||||
{
|
{
|
||||||
@ -333,7 +333,7 @@ private function reviewTenants(): array
|
|||||||
return $this->reviewTenants = [];
|
return $this->reviewTenants = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$service = app(TenantReviewRegisterService::class);
|
$service = app(EnvironmentReviewRegisterService::class);
|
||||||
|
|
||||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||||
return $this->reviewTenants = [];
|
return $this->reviewTenants = [];
|
||||||
@ -343,7 +343,7 @@ private function reviewTenants(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
private function authorizedTenants(): array
|
private function authorizedTenants(): array
|
||||||
{
|
{
|
||||||
@ -358,17 +358,16 @@ private function authorizedTenants(): array
|
|||||||
return $this->authorizedTenants = [];
|
return $this->authorizedTenants = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authorizedTenants = $user->tenants()
|
return $this->authorizedTenants = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
->where('managed_environments.lifecycle_status', 'active')
|
||||||
->where('tenants.status', 'active')
|
->orderBy('managed_environments.name')
|
||||||
->orderBy('tenants.name')
|
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
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)) {
|
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||||
return;
|
return;
|
||||||
@ -490,7 +489,7 @@ private function unfilteredInboxPayload(): array
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function selectedTenant(): ?Tenant
|
private function selectedTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
if (! is_int($this->tenantId)) {
|
if (! is_int($this->tenantId)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -9,8 +9,9 @@
|
|||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
@ -20,6 +21,7 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Inventory\TenantCoverageTruth;
|
use App\Support\Inventory\TenantCoverageTruth;
|
||||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -38,6 +40,7 @@
|
|||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -77,11 +80,38 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $parameters
|
||||||
|
*/
|
||||||
|
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string
|
||||||
|
{
|
||||||
|
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||||
|
|
||||||
|
if ($panelId !== 'admin') {
|
||||||
|
return parent::getUrl($parameters, $isAbsolute, $panelId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
$resolvedTenant = static::resolveAdminUrlTenant($parameters, $tenant);
|
||||||
|
|
||||||
|
if (! $resolvedTenant instanceof ManagedEnvironment) {
|
||||||
|
return url('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = static::resolveAdminUrlWorkspace($resolvedTenant, $parameters);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace && ! is_string($workspace) && ! is_int($workspace)) {
|
||||||
|
return url('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters['environment'] ??= $resolvedTenant;
|
||||||
|
$parameters['workspace'] ??= $workspace;
|
||||||
|
unset($parameters['tenant']);
|
||||||
|
|
||||||
|
return parent::getUrl($parameters, $isAbsolute, $panelId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
@ -89,7 +119,7 @@ public static function canAccess(): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -509,7 +539,7 @@ public function basisRunSummary(): array
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
|
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof ManagedEnvironment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -523,7 +553,7 @@ public function basisRunSummary(): array
|
|||||||
'badgeColor' => null,
|
'badgeColor' => null,
|
||||||
'runUrl' => null,
|
'runUrl' => null,
|
||||||
'historyUrl' => null,
|
'historyUrl' => null,
|
||||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -539,7 +569,7 @@ public function basisRunSummary(): array
|
|||||||
'badgeColor' => $badge->color,
|
'badgeColor' => $badge->color,
|
||||||
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
||||||
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -551,7 +581,7 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
|||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,8 +590,46 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
|||||||
return $this->cachedCoverageTruth;
|
return $this->cachedCoverageTruth;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
private function inventorySyncHistoryUrl(ManagedEnvironment $tenant): string
|
||||||
{
|
{
|
||||||
return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value);
|
return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $parameters
|
||||||
|
*/
|
||||||
|
private static function resolveAdminUrlTenant(array $parameters, ?Model $tenant = null): ?ManagedEnvironment
|
||||||
|
{
|
||||||
|
$parameterTenant = $parameters['tenant'] ?? $parameters['environment'] ?? null;
|
||||||
|
|
||||||
|
if ($parameterTenant instanceof ManagedEnvironment) {
|
||||||
|
return $parameterTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::resolveTenantContextForCurrentPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $parameters
|
||||||
|
*/
|
||||||
|
private static function resolveAdminUrlWorkspace(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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
namespace App\Filament\Pages\Monitoring;
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
use App\Models\AuditLog as AuditLogModel;
|
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\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
@ -38,6 +39,7 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -60,7 +62,17 @@ class AuditLog extends Page implements HasTable
|
|||||||
'invalidFallback' => 'clear_selection_and_continue',
|
'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',
|
'stateClass' => 'contextual_prefilter',
|
||||||
'carrier' => 'session',
|
'carrier' => 'session',
|
||||||
'queryRole' => 'durable_restorable',
|
'queryRole' => 'durable_restorable',
|
||||||
@ -84,7 +96,7 @@ class AuditLog extends Page implements HasTable
|
|||||||
'precedenceOrder' => ['query', 'session', 'default'],
|
'precedenceOrder' => ['query', 'session', 'default'],
|
||||||
'appliesOnInitialMountOnly' => true,
|
'appliesOnInitialMountOnly' => true,
|
||||||
'activeStateBecomesAuthoritativeAfterMount' => 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',
|
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||||
],
|
],
|
||||||
'inspectContract' => [
|
'inspectContract' => [
|
||||||
@ -95,12 +107,14 @@ class AuditLog extends Page implements HasTable
|
|||||||
'shareable' => true,
|
'shareable' => true,
|
||||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||||
],
|
],
|
||||||
'shareableStateKeys' => ['event'],
|
'shareableStateKeys' => ['event', 'supportAccess'],
|
||||||
'localOnlyStateKeys' => [],
|
'localOnlyStateKeys' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
public ?int $selectedAuditLogId = null;
|
public ?int $selectedAuditLogId = null;
|
||||||
|
|
||||||
|
public bool $supportAccessOnly = false;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -118,7 +132,7 @@ class AuditLog extends Page implements HasTable
|
|||||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $authorizedTenants = null;
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
@ -147,6 +161,7 @@ public static function monitoringPageStateContract(): array
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
|
$this->supportAccessOnly = request()->boolean('supportAccess');
|
||||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
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();
|
$selectedAudit = $this->selectedAuditRecord();
|
||||||
$selectedAuditLink = $selectedAudit instanceof AuditLogModel
|
$selectedAuditLink = $selectedAudit instanceof AuditLogModel
|
||||||
? $this->auditTargetLink($selectedAudit)
|
? $this->auditTargetLink($selectedAudit)
|
||||||
@ -250,7 +281,7 @@ public function table(Table $table): Table
|
|||||||
->searchable()
|
->searchable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
|
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('recorded_at')
|
TextColumn::make('recorded_at')
|
||||||
@ -259,8 +290,8 @@ public function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -304,7 +335,7 @@ public function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function authorizedTenants(): array
|
public function authorizedTenants(): array
|
||||||
{
|
{
|
||||||
@ -320,9 +351,9 @@ public function authorizedTenants(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId)
|
->filter(static fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId)
|
||||||
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
->filter(static fn (ManagedEnvironment $tenant): bool => $tenant->isActive())
|
||||||
->keyBy(fn (Tenant $tenant): int => (int) $tenant->getKey())
|
->keyBy(fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
return $this->authorizedTenants = $tenants;
|
return $this->authorizedTenants = $tenants;
|
||||||
@ -358,7 +389,7 @@ private function auditBaseQuery(): Builder
|
|||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$authorizedTenantIds = array_map(
|
$authorizedTenantIds = array_map(
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||||
$this->authorizedTenants(),
|
$this->authorizedTenants(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -366,12 +397,15 @@ private function auditBaseQuery(): Builder
|
|||||||
->with(['tenant', 'workspace', 'operationRun'])
|
->with(['tenant', 'workspace', 'operationRun'])
|
||||||
->forWorkspace((int) $workspaceId)
|
->forWorkspace((int) $workspaceId)
|
||||||
->where(function (Builder $query) use ($authorizedTenantIds): void {
|
->where(function (Builder $query) use ($authorizedTenantIds): void {
|
||||||
$query->whereNull('tenant_id');
|
$query->whereNull('managed_environment_id');
|
||||||
|
|
||||||
if ($authorizedTenantIds !== []) {
|
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();
|
->latestFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,6 +467,7 @@ private function auditLogUrl(array $overrides = []): string
|
|||||||
{
|
{
|
||||||
$parameters = array_merge(
|
$parameters = array_merge(
|
||||||
$this->navigationContext()?->toQuery() ?? [],
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
|
['supportAccess' => $this->supportAccessOnly ? true : null],
|
||||||
['event' => $this->selectedAuditLogId],
|
['event' => $this->selectedAuditLogId],
|
||||||
$overrides,
|
$overrides,
|
||||||
);
|
);
|
||||||
@ -508,11 +543,18 @@ private function currentTableSearchState(): string
|
|||||||
|
|
||||||
private function matchesSelectedAuditFilters(AuditLogModel $record): bool
|
private function matchesSelectedAuditFilters(AuditLogModel $record): bool
|
||||||
{
|
{
|
||||||
|
if (
|
||||||
|
$this->supportAccessOnly
|
||||||
|
&& ! in_array((string) $record->action, SupportAccessGrant::supportAccessAuditActions(), true)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$filters = $this->currentTableFiltersState();
|
$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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -566,7 +608,7 @@ private function matchesSelectedAuditSearch(AuditLogModel $record): bool
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return collect($this->authorizedTenants())
|
return collect($this->authorizedTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -576,7 +618,7 @@ private function defaultTenantFilter(): ?string
|
|||||||
{
|
{
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -632,4 +674,69 @@ private function targetTypeFilterOptions(): array
|
|||||||
|
|
||||||
return FilterOptionCatalog::auditTargetTypes($values);
|
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,12 +6,12 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\TenantReview;
|
use App\Models\EnvironmentReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\EnvironmentReviewStatus;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -46,7 +46,7 @@ class EvidenceOverview extends Page implements HasTable
|
|||||||
'surfaceType' => 'simple_monitoring',
|
'surfaceType' => 'simple_monitoring',
|
||||||
'stateFields' => [
|
'stateFields' => [
|
||||||
[
|
[
|
||||||
'stateKey' => 'tenant_id',
|
'stateKey' => 'managed_environment_id',
|
||||||
'stateClass' => 'contextual_prefilter',
|
'stateClass' => 'contextual_prefilter',
|
||||||
'carrier' => 'query_param',
|
'carrier' => 'query_param',
|
||||||
'queryRole' => 'durable_restorable',
|
'queryRole' => 'durable_restorable',
|
||||||
@ -90,7 +90,7 @@ class EvidenceOverview extends Page implements HasTable
|
|||||||
'precedenceOrder' => ['query', 'session', 'default'],
|
'precedenceOrder' => ['query', 'session', 'default'],
|
||||||
'appliesOnInitialMountOnly' => true,
|
'appliesOnInitialMountOnly' => true,
|
||||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||||
'clearsOnTenantSwitch' => ['tenant_id'],
|
'clearsOnTenantSwitch' => ['managed_environment_id'],
|
||||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||||
],
|
],
|
||||||
'inspectContract' => [
|
'inspectContract' => [
|
||||||
@ -101,7 +101,7 @@ class EvidenceOverview extends Page implements HasTable
|
|||||||
'shareable' => false,
|
'shareable' => false,
|
||||||
'invalidSelectionFallback' => 'discard_and_continue',
|
'invalidSelectionFallback' => 'discard_and_continue',
|
||||||
],
|
],
|
||||||
'shareableStateKeys' => ['tenant_id', 'search'],
|
'shareableStateKeys' => ['managed_environment_id', 'search'],
|
||||||
'localOnlyStateKeys' => [],
|
'localOnlyStateKeys' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -123,7 +123,7 @@ class EvidenceOverview extends Page implements HasTable
|
|||||||
public array $rows = [];
|
public array $rows = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $accessibleTenants = null;
|
private ?array $accessibleTenants = null;
|
||||||
|
|
||||||
@ -181,14 +181,14 @@ public function table(Table $table): Table
|
|||||||
return $this->paginateRows($rows, $page, $recordsPerPage);
|
return $this->paginateRows($rows, $page, $recordsPerPage);
|
||||||
})
|
})
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('tenant_name')
|
TextColumn::make('tenant_name')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('artifact_truth_label')
|
TextColumn::make('artifact_truth_label')
|
||||||
->label('Outcome')
|
->label('Outcome')
|
||||||
@ -240,7 +240,7 @@ protected function getHeaderActions(): array
|
|||||||
public function clearOverviewFilters(): void
|
public function clearOverviewFilters(): void
|
||||||
{
|
{
|
||||||
$this->tableFilters = [
|
$this->tableFilters = [
|
||||||
'tenant_id' => ['value' => null],
|
'managed_environment_id' => ['value' => null],
|
||||||
];
|
];
|
||||||
$this->tableDeferredFilters = $this->tableFilters;
|
$this->tableDeferredFilters = $this->tableFilters;
|
||||||
$this->tableSearch = '';
|
$this->tableSearch = '';
|
||||||
@ -284,7 +284,7 @@ private function authorizeWorkspaceAccess(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
private function accessibleTenants(): array
|
private function accessibleTenants(): array
|
||||||
{
|
{
|
||||||
@ -300,11 +300,10 @@ private function accessibleTenants(): array
|
|||||||
|
|
||||||
$workspaceId = $this->workspaceId();
|
$workspaceId = $this->workspaceId();
|
||||||
|
|
||||||
return $this->accessibleTenants = $user->tenants()
|
return $this->accessibleTenants = $user->accessibleManagedEnvironmentsQuery($workspaceId)
|
||||||
->where('tenants.workspace_id', $workspaceId)
|
->orderBy('managed_environments.name')
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get()
|
->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()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -315,7 +314,7 @@ private function accessibleTenants(): array
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return collect($this->accessibleTenants())
|
return collect($this->accessibleTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
(string) $tenant->getKey() => $tenant->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -328,11 +327,11 @@ private function tenantFilterOptions(): array
|
|||||||
private function rowsForState(array $filters = [], ?string $search = null): Collection
|
private function rowsForState(array $filters = [], ?string $search = null): Collection
|
||||||
{
|
{
|
||||||
$rows = $this->baseRows();
|
$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)));
|
$normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch)));
|
||||||
|
|
||||||
if ($tenantFilter !== null) {
|
if ($tenantFilter !== null) {
|
||||||
$rows = $rows->where('tenant_id', $tenantFilter);
|
$rows = $rows->where('managed_environment_id', $tenantFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalizedSearch === '') {
|
if ($normalizedSearch === '') {
|
||||||
@ -375,7 +374,7 @@ private function latestAccessibleSnapshots(): Collection
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantIds = collect($this->accessibleTenants())
|
$tenantIds = collect($this->accessibleTenants())
|
||||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
->map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$query = EvidenceSnapshot::query()
|
$query = EvidenceSnapshot::query()
|
||||||
@ -387,10 +386,10 @@ private function latestAccessibleSnapshots(): Collection
|
|||||||
if ($tenantIds === []) {
|
if ($tenantIds === []) {
|
||||||
$query->whereRaw('1 = 0');
|
$query->whereRaw('1 = 0');
|
||||||
} else {
|
} 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -399,15 +398,15 @@ private function latestAccessibleSnapshots(): Collection
|
|||||||
*/
|
*/
|
||||||
private function currentReviewTenantIds(Collection $snapshots): array
|
private function currentReviewTenantIds(Collection $snapshots): array
|
||||||
{
|
{
|
||||||
return TenantReview::query()
|
return EnvironmentReview::query()
|
||||||
->where('workspace_id', $this->workspaceId())
|
->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', [
|
->whereIn('status', [
|
||||||
TenantReviewStatus::Draft->value,
|
EnvironmentReviewStatus::Draft->value,
|
||||||
TenantReviewStatus::Ready->value,
|
EnvironmentReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
EnvironmentReviewStatus::Published->value,
|
||||||
])
|
])
|
||||||
->pluck('tenant_id')
|
->pluck('managed_environment_id')
|
||||||
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -420,7 +419,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
|||||||
{
|
{
|
||||||
$truth = $this->snapshotTruth($snapshot);
|
$truth = $this->snapshotTruth($snapshot);
|
||||||
$outcome = $this->snapshotOutcome($snapshot);
|
$outcome = $this->snapshotOutcome($snapshot);
|
||||||
$tenantId = (int) $snapshot->tenant_id;
|
$tenantId = (int) $snapshot->managed_environment_id;
|
||||||
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
|
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
|
||||||
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
|
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
|
||||||
? 'Create a current review from this evidence snapshot'
|
? 'Create a current review from this evidence snapshot'
|
||||||
@ -428,7 +427,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
'tenant_id' => $tenantId,
|
'managed_environment_id' => $tenantId,
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
'artifact_truth_label' => $outcome->primaryLabel,
|
'artifact_truth_label' => $outcome->primaryLabel,
|
||||||
@ -443,7 +442,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
|||||||
],
|
],
|
||||||
'next_step' => $nextStep,
|
'next_step' => $nextStep,
|
||||||
'view_url' => $snapshot->tenant
|
'view_url' => $snapshot->tenant
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'tenant')
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
: null,
|
: null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -495,18 +494,18 @@ private function seedTableStateFromQuery(): void
|
|||||||
$this->tableSearch = trim((string) request()->query('search', ''));
|
$this->tableSearch = trim((string) request()->query('search', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! array_key_exists('tenant_id', $query)) {
|
if (! array_key_exists('managed_environment_id', $query)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id'));
|
$tenantFilter = $this->normalizeTenantFilter(request()->query('managed_environment_id'));
|
||||||
|
|
||||||
if ($tenantFilter === null) {
|
if ($tenantFilter === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tableFilters = [
|
$this->tableFilters = [
|
||||||
'tenant_id' => ['value' => (string) $tenantFilter],
|
'managed_environment_id' => ['value' => (string) $tenantFilter],
|
||||||
];
|
];
|
||||||
$this->tableDeferredFilters = $this->tableFilters;
|
$this->tableDeferredFilters = $this->tableFilters;
|
||||||
}
|
}
|
||||||
@ -519,7 +518,7 @@ private function normalizeTenantFilter(mixed $value): ?int
|
|||||||
|
|
||||||
$requestedTenantId = (int) $value;
|
$requestedTenantId = (int) $value;
|
||||||
$allowedTenantIds = collect($this->accessibleTenants())
|
$allowedTenantIds = collect($this->accessibleTenants())
|
||||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
->map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
return in_array($requestedTenantId, $allowedTenantIds, true)
|
return in_array($requestedTenantId, $allowedTenantIds, true)
|
||||||
@ -529,7 +528,7 @@ private function normalizeTenantFilter(mixed $value): ?int
|
|||||||
|
|
||||||
private function hasActiveOverviewFilters(): bool
|
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) !== '';
|
|| trim((string) $this->tableSearch) !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
@ -101,7 +101,7 @@ class FindingExceptionsQueue extends Page implements HasTable
|
|||||||
'precedenceOrder' => ['query', 'session', 'default'],
|
'precedenceOrder' => ['query', 'session', 'default'],
|
||||||
'appliesOnInitialMountOnly' => true,
|
'appliesOnInitialMountOnly' => true,
|
||||||
'activeStateBecomesAuthoritativeAfterMount' => 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',
|
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||||
],
|
],
|
||||||
'inspectContract' => [
|
'inspectContract' => [
|
||||||
@ -133,7 +133,7 @@ class FindingExceptionsQueue extends Page implements HasTable
|
|||||||
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $authorizedTenants = null;
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
@ -147,7 +147,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to environment findings available.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +224,7 @@ protected function getHeaderActions(): array
|
|||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
$this->removeTableFilter('status');
|
$this->removeTableFilter('status');
|
||||||
$this->removeTableFilter('current_validity_state');
|
$this->removeTableFilter('current_validity_state');
|
||||||
$this->selectedFindingExceptionId = null;
|
$this->selectedFindingExceptionId = null;
|
||||||
@ -232,18 +232,18 @@ protected function getHeaderActions(): array
|
|||||||
});
|
});
|
||||||
|
|
||||||
$actions[] = Action::make('view_tenant_register')
|
$actions[] = Action::make('view_tenant_register')
|
||||||
->label('View tenant register')
|
->label('View environment findings')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
|
->visible(fn (): bool => $this->filteredTenant() instanceof ManagedEnvironment)
|
||||||
->url(function (): ?string {
|
->url(function (): ?string {
|
||||||
$tenant = $this->filteredTenant();
|
$tenant = $this->filteredTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
return FindingExceptionResource::getUrl('index', tenant: $tenant);
|
||||||
});
|
});
|
||||||
|
|
||||||
$selectedContextActions = [
|
$selectedContextActions = [
|
||||||
@ -254,7 +254,7 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => $this->queueUrl(['exception' => null])),
|
->url(fn (): string => $this->queueUrl(['exception' => null])),
|
||||||
|
|
||||||
Action::make('open_selected_exception')
|
Action::make('open_selected_exception')
|
||||||
->label('Open tenant detail')
|
->label('Open environment detail')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||||
@ -383,7 +383,7 @@ public function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('finding_summary')
|
TextColumn::make('finding_summary')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
@ -416,8 +416,8 @@ public function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
SelectFilter::make('status')
|
SelectFilter::make('status')
|
||||||
@ -443,7 +443,7 @@ public function table(Table $table): Table
|
|||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->removeTableFilter('tenant_id');
|
$this->removeTableFilter('managed_environment_id');
|
||||||
$this->removeTableFilter('status');
|
$this->removeTableFilter('status');
|
||||||
$this->removeTableFilter('current_validity_state');
|
$this->removeTableFilter('current_validity_state');
|
||||||
$this->selectedFindingExceptionId = null;
|
$this->selectedFindingExceptionId = null;
|
||||||
@ -490,7 +490,7 @@ public function selectedExceptionUrl(): ?string
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->appendQuery(
|
return $this->appendQuery(
|
||||||
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
|
FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $record->tenant),
|
||||||
$this->navigationContext()?->toQuery() ?? [],
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -504,7 +504,7 @@ public function selectedFindingUrl(): ?string
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->appendQuery(
|
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() ?? [],
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -515,7 +515,7 @@ public function clearSelectedException(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function authorizedTenants(): array
|
public function authorizedTenants(): array
|
||||||
{
|
{
|
||||||
@ -535,13 +535,12 @@ public function authorizedTenants(): array
|
|||||||
return $this->authorizedTenants = [];
|
return $this->authorizedTenants = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenants = $user->tenants()
|
$tenants = $user->accessibleManagedEnvironmentsQuery($workspaceId)
|
||||||
->where('tenants.workspace_id', $workspaceId)
|
->orderBy('managed_environments.name')
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return $this->authorizedTenants = $tenants
|
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()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -550,7 +549,7 @@ private function queueBaseQuery(): Builder
|
|||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$tenantIds = array_values(array_map(
|
$tenantIds = array_values(array_map(
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
||||||
$this->authorizedTenants(),
|
$this->authorizedTenants(),
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -565,7 +564,7 @@ private function queueBaseQuery(): Builder
|
|||||||
'evidenceReferences',
|
'evidenceReferences',
|
||||||
])
|
])
|
||||||
->where('workspace_id', (int) $workspaceId)
|
->where('workspace_id', (int) $workspaceId)
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
|
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -574,7 +573,7 @@ private function queueBaseQuery(): Builder
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return Collection::make($this->authorizedTenants())
|
return Collection::make($this->authorizedTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
(string) $tenant->getKey() => $tenant->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -593,14 +592,14 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
private function filteredTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenantId = $this->currentTenantFilterId();
|
$tenantId = $this->currentTenantFilterId();
|
||||||
|
|
||||||
@ -741,9 +740,9 @@ private function matchesSelectedFindingExceptionFilters(FindingException $record
|
|||||||
{
|
{
|
||||||
$filters = $this->currentQueueFiltersState();
|
$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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,10 +7,13 @@
|
|||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
@ -22,6 +25,8 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -44,7 +49,7 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
'surfaceType' => 'simple_monitoring',
|
'surfaceType' => 'simple_monitoring',
|
||||||
'stateFields' => [
|
'stateFields' => [
|
||||||
[
|
[
|
||||||
'stateKey' => 'tenant_id',
|
'stateKey' => 'managed_environment_id',
|
||||||
'stateClass' => 'contextual_prefilter',
|
'stateClass' => 'contextual_prefilter',
|
||||||
'carrier' => 'query_param',
|
'carrier' => 'query_param',
|
||||||
'queryRole' => 'durable_restorable',
|
'queryRole' => 'durable_restorable',
|
||||||
@ -98,7 +103,7 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
'precedenceOrder' => ['query', 'session', 'default'],
|
'precedenceOrder' => ['query', 'session', 'default'],
|
||||||
'appliesOnInitialMountOnly' => true,
|
'appliesOnInitialMountOnly' => true,
|
||||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||||
'clearsOnTenantSwitch' => ['tenant_id', 'type', 'initiator_name'],
|
'clearsOnTenantSwitch' => ['managed_environment_id', 'type', 'initiator_name'],
|
||||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||||
],
|
],
|
||||||
'inspectContract' => [
|
'inspectContract' => [
|
||||||
@ -109,7 +114,7 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
'shareable' => false,
|
'shareable' => false,
|
||||||
'invalidSelectionFallback' => 'discard_and_continue',
|
'invalidSelectionFallback' => 'discard_and_continue',
|
||||||
],
|
],
|
||||||
'shareableStateKeys' => ['tenant_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
'shareableStateKeys' => ['managed_environment_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
||||||
'localOnlyStateKeys' => [],
|
'localOnlyStateKeys' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -153,8 +158,55 @@ public static function monitoringPageStateContract(): array
|
|||||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! is_int($workspaceId)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
|
|
||||||
$this->applyRequestedTenantScope();
|
$this->applyRequestedTenantScope();
|
||||||
@ -199,26 +251,26 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-arrow-left')
|
->icon('heroicon-o-arrow-left')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url($navigationContext->backLinkUrl);
|
->url($navigationContext->backLinkUrl);
|
||||||
} elseif ($activeTenant instanceof Tenant) {
|
} elseif ($activeTenant instanceof ManagedEnvironment) {
|
||||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||||
->label('Back to '.$activeTenant->name)
|
->label('Back to '.$activeTenant->name)
|
||||||
->icon('heroicon-o-arrow-left')
|
->icon('heroicon-o-arrow-left')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
->url(ManagedEnvironmentLinks::viewUrl($activeTenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
if ($activeTenant instanceof ManagedEnvironment) {
|
||||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||||
->label('Show all tenants')
|
->label(__('localization.shell.show_all_environments'))
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
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,21 +300,21 @@ public function landingHierarchySummary(): array
|
|||||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
$returnLabel = $navigationContext->backLinkLabel;
|
$returnLabel = $navigationContext->backLinkLabel;
|
||||||
$returnBody = 'Return to the originating monitoring surface without competing with the current tab, filters, or row inspection flow.';
|
$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;
|
$returnLabel = 'Back to '.$activeTenant->name;
|
||||||
$returnBody = 'Return to the tenant dashboard when you need tenant-specific context outside this workspace monitoring landing.';
|
$returnBody = 'Return to the tenant dashboard when you need tenant-specific context outside this workspace monitoring landing.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'scope_label' => $operateHubShell->scopeLabel(request()),
|
'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 narrowed to one environment inside the active workspace.'
|
||||||
: 'The landing is currently showing workspace-wide monitoring across all entitled tenants.',
|
: 'The landing is currently showing workspace-wide monitoring across all entitled environments.',
|
||||||
'return_label' => $returnLabel,
|
'return_label' => $returnLabel,
|
||||||
'return_body' => $returnBody,
|
'return_body' => $returnBody,
|
||||||
'scope_reset_label' => $activeTenant instanceof Tenant ? 'Show all tenants' : null,
|
'scope_reset_label' => $activeTenant instanceof ManagedEnvironment ? __('localization.shell.show_all_environments') : null,
|
||||||
'scope_reset_body' => $activeTenant instanceof Tenant
|
'scope_reset_body' => $activeTenant instanceof ManagedEnvironment
|
||||||
? 'Reset the landing back to workspace-wide monitoring when tenant-specific context is no longer needed.'
|
? 'Reset the landing back to workspace-wide monitoring when environment-specific context is no longer needed.'
|
||||||
: null,
|
: null,
|
||||||
'inspect_body' => 'Open a run from the table to enter the canonical monitoring detail viewer.',
|
'inspect_body' => 'Open a run from the table to enter the canonical monitoring detail viewer.',
|
||||||
];
|
];
|
||||||
@ -298,6 +350,7 @@ public function table(Table $table): Table
|
|||||||
->query(function (): Builder {
|
->query(function (): Builder {
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$tenantFilter = $this->currentTenantFilterId();
|
$tenantFilter = $this->currentTenantFilterId();
|
||||||
|
$allowedTenantIds = $this->allowedTenantIdsForWorkspaceScope($workspaceId);
|
||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->with('user')
|
->with('user')
|
||||||
@ -310,9 +363,21 @@ public function table(Table $table): Table
|
|||||||
! $workspaceId,
|
! $workspaceId,
|
||||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
)
|
)
|
||||||
|
->when(
|
||||||
|
$workspaceId && $allowedTenantIds !== null,
|
||||||
|
function (Builder $query) use ($allowedTenantIds): Builder {
|
||||||
|
return $query->where(function (Builder $query) use ($allowedTenantIds): void {
|
||||||
|
$query->whereNull('managed_environment_id');
|
||||||
|
|
||||||
|
if ($allowedTenantIds !== []) {
|
||||||
|
$query->orWhereIn('managed_environment_id', $allowedTenantIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
->when(
|
->when(
|
||||||
$tenantFilter !== null,
|
$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);
|
return $this->applyActiveTab($query);
|
||||||
@ -388,24 +453,37 @@ private function scopedSummaryQuery(): ?Builder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantFilter = $this->currentTenantFilterId();
|
$tenantFilter = $this->currentTenantFilterId();
|
||||||
|
$allowedTenantIds = $this->allowedTenantIdsForWorkspaceScope($workspaceId);
|
||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('workspace_id', (int) $workspaceId)
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when(
|
||||||
|
$allowedTenantIds !== null,
|
||||||
|
function (Builder $query) use ($allowedTenantIds): Builder {
|
||||||
|
return $query->where(function (Builder $query) use ($allowedTenantIds): void {
|
||||||
|
$query->whereNull('managed_environment_id');
|
||||||
|
|
||||||
|
if ($allowedTenantIds !== []) {
|
||||||
|
$query->orWhereIn('managed_environment_id', $allowedTenantIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
->when(
|
->when(
|
||||||
$tenantFilter !== null,
|
$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
|
private function applyRequestedDashboardPrefilter(): void
|
||||||
{
|
{
|
||||||
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||||
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('tenant_id'));
|
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('managed_environment_id'));
|
||||||
|
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
$tenantId = (string) $requestedTenantId;
|
$tenantId = (string) $requestedTenantId;
|
||||||
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
$this->tableFilters['managed_environment_id']['value'] = $tenantId;
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
$this->tableDeferredFilters['managed_environment_id']['value'] = $tenantId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,10 +510,11 @@ private function shouldForceWorkspaceWideTenantScope(): bool
|
|||||||
private function operationsUrl(array $overrides = []): string
|
private function operationsUrl(array $overrides = []): string
|
||||||
{
|
{
|
||||||
$parameters = array_merge(
|
$parameters = array_merge(
|
||||||
|
['workspace' => app(WorkspaceContext::class)->currentWorkspace(request())],
|
||||||
$this->navigationContext()?->toQuery() ?? [],
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
[
|
[
|
||||||
'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null,
|
'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,
|
'activeTab' => $this->activeTab !== 'all' ? $this->activeTab : null,
|
||||||
'problemClass' => in_array($this->activeTab, self::problemClassTabs(), true) ? $this->activeTab : null,
|
'problemClass' => in_array($this->activeTab, self::problemClassTabs(), true) ? $this->activeTab : null,
|
||||||
],
|
],
|
||||||
@ -459,6 +538,29 @@ private function currentTenantFilterId(): ?int
|
|||||||
return $this->normalizeEntitledTenantFilter($tenantFilter);
|
return $this->normalizeEntitledTenantFilter($tenantFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null means inherited access to all environments in the workspace.
|
||||||
|
*
|
||||||
|
* @return list<int>|null
|
||||||
|
*/
|
||||||
|
private function allowedTenantIdsForWorkspaceScope(mixed $workspaceId): ?array
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! is_int($workspaceId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedIds = app(ManagedEnvironmentAccessScopeResolver::class)
|
||||||
|
->allowedManagedEnvironmentIdsForWorkspace($user, $workspaceId);
|
||||||
|
|
||||||
|
if ($allowedIds === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map('intval', $allowedIds)));
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeEntitledTenantFilter(mixed $value): ?int
|
private function normalizeEntitledTenantFilter(mixed $value): ?int
|
||||||
{
|
{
|
||||||
if (! is_numeric($value)) {
|
if (! is_numeric($value)) {
|
||||||
@ -485,9 +587,9 @@ private function authorizedTenantIds(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
->filter(static fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||||
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
->filter(static fn (ManagedEnvironment $tenant): bool => $tenant->isActive())
|
||||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
->map(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,6 +93,6 @@ public function createWorkspace(array $data): void
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$this->redirect(ChooseTenant::getUrl());
|
$this->redirect(ChooseEnvironment::getUrl());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\SupportRequest;
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
@ -108,7 +109,7 @@ protected function getHeaderActions(): array
|
|||||||
$operateHubShell = app(OperateHubShell::class);
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
$navigationContext = $this->navigationContext();
|
$navigationContext = $this->navigationContext();
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
$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 = [
|
$actions = [
|
||||||
Action::make('operate_hub_scope_run_detail')
|
Action::make('operate_hub_scope_run_detail')
|
||||||
@ -122,11 +123,11 @@ protected function getHeaderActions(): array
|
|||||||
->label($navigationContext->backLinkLabel)
|
->label($navigationContext->backLinkLabel)
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url($navigationContext->backLinkUrl);
|
->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')
|
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||||
->label('← Back to '.$activeTenant->name)
|
->label('← Back to '.$activeTenant->name)
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
->url(ManagedEnvironmentLinks::viewUrl($activeTenant));
|
||||||
} else {
|
} else {
|
||||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||||
->label('Back to Operations')
|
->label('Back to Operations')
|
||||||
@ -134,7 +135,7 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => OperationRunLinks::index());
|
->url(fn (): string => OperationRunLinks::index());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
if ($activeTenant instanceof ManagedEnvironment) {
|
||||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||||
->label('Show all operations')
|
->label('Show all operations')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
@ -201,7 +202,7 @@ public function monitoringDetailSummary(): array
|
|||||||
$operateHubShell = app(OperateHubShell::class);
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
$navigationContext = $this->navigationContext();
|
$navigationContext = $this->navigationContext();
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
$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';
|
$navigationLabel = 'Back to Operations';
|
||||||
$navigationBody = 'Return to the operations landing when this review is complete.';
|
$navigationBody = 'Return to the operations landing when this review is complete.';
|
||||||
@ -209,7 +210,7 @@ public function monitoringDetailSummary(): array
|
|||||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
$navigationLabel = $navigationContext->backLinkLabel;
|
$navigationLabel = $navigationContext->backLinkLabel;
|
||||||
$navigationBody = 'Return to the originating surface while keeping refresh and follow-up work separate from navigation.';
|
$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;
|
$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.';
|
$navigationBody = 'Return to the active tenant dashboard, then widen back to the workspace view only when you need broader monitoring context.';
|
||||||
}
|
}
|
||||||
@ -391,7 +392,7 @@ private function auditOperationSupportDiagnosticsOpen(): void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function supportDiagnosticsTenant(): ?Tenant
|
private function supportDiagnosticsTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return null;
|
return null;
|
||||||
@ -399,7 +400,7 @@ private function supportDiagnosticsTenant(): ?Tenant
|
|||||||
|
|
||||||
$tenant = $this->run->tenant;
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,12 +418,12 @@ private function resolveViewerActor(): User
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveRunTenantForCapability(string $capability): Tenant
|
private function resolveRunTenantForCapability(string $capability): ManagedEnvironment
|
||||||
{
|
{
|
||||||
$tenant = $this->supportDiagnosticsTenant();
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
$user = $this->resolveViewerActor();
|
$user = $this->resolveViewerActor();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,7 +445,7 @@ private function operationSupportRequestAttachmentSummary(): string
|
|||||||
$tenant = $this->supportDiagnosticsTenant();
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
$user = auth()->user();
|
$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.';
|
return 'Only canonical redacted run context will be attached.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,7 +555,7 @@ private function supportRequestNotificationBody(SupportRequest $supportRequest):
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $bundle
|
* @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)) {
|
if (! isset($this->run)) {
|
||||||
return;
|
return;
|
||||||
@ -729,39 +730,39 @@ public function canonicalContextBanner(): ?array
|
|||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
$runTenant = $this->run->tenant;
|
$runTenant = $this->run->tenant;
|
||||||
|
|
||||||
if (! $runTenant instanceof Tenant) {
|
if (! $runTenant instanceof ManagedEnvironment) {
|
||||||
return [
|
return [
|
||||||
'tone' => 'slate',
|
'tone' => 'slate',
|
||||||
'title' => 'Workspace-level operation',
|
'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 the current environment context ('.$activeTenant->name.').'
|
||||||
: 'This canonical workspace view is not tied to any tenant.',
|
: 'This canonical workspace view is not tied to any environment.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$messages = ['Operation tenant: '.$runTenant->name.'.'];
|
$messages = ['Operation environment: '.$runTenant->name.'.'];
|
||||||
$tone = 'sky';
|
$tone = 'sky';
|
||||||
$title = null;
|
$title = null;
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
|
if ($activeTenant instanceof ManagedEnvironment && ! $activeTenant->is($runTenant)) {
|
||||||
$title = 'Current tenant context differs from this operation';
|
$title = 'Current environment context differs from this operation';
|
||||||
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
|
array_unshift($messages, 'Current environment context: '.$activeTenant->name.'.');
|
||||||
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
|
$messages[] = 'This canonical workspace view remains valid without switching environment context.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
|
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
|
||||||
|
|
||||||
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
|
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
|
||||||
$title ??= 'Operation tenant is not available in the current tenant selector';
|
$title ??= 'Operation environment is not available in the current environment selector';
|
||||||
$tone = 'amber';
|
$tone = 'amber';
|
||||||
$messages[] = $selectorAvailabilityMessage;
|
$messages[] = $selectorAvailabilityMessage;
|
||||||
|
|
||||||
if ($referencedTenant->contextNote !== null) {
|
if ($referencedTenant->contextNote !== null) {
|
||||||
$messages[] = $referencedTenant->contextNote;
|
$messages[] = $referencedTenant->contextNote;
|
||||||
}
|
}
|
||||||
} elseif (! $activeTenant instanceof Tenant) {
|
} elseif (! $activeTenant instanceof ManagedEnvironment) {
|
||||||
$title ??= 'Canonical workspace view';
|
$title ??= 'Canonical workspace view';
|
||||||
$messages[] = 'No tenant context is currently selected.';
|
$messages[] = 'No environment context is currently selected.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($title === null) {
|
if ($title === null) {
|
||||||
@ -925,7 +926,7 @@ private function canResumeCapture(): bool
|
|||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function relatedLinksTenant(): ?Tenant
|
private function relatedLinksTenant(): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return null;
|
return null;
|
||||||
@ -934,7 +935,7 @@ private function relatedLinksTenant(): ?Tenant
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = $this->run->tenant;
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,19 +4,24 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Reviews;
|
namespace App\Filament\Pages\Reviews;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\EnvironmentReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\TenantReview;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\EnvironmentReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
|
use App\Support\EnvironmentReviewCompletenessState;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -36,6 +41,7 @@
|
|||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -45,7 +51,7 @@ class CustomerReviewWorkspace extends Page implements HasTable
|
|||||||
|
|
||||||
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
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;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
@ -67,10 +73,11 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
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::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.')
|
->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.')
|
->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
|
public static function getNavigationGroup(): string
|
||||||
@ -88,7 +95,7 @@ public function getTitle(): string
|
|||||||
return __('localization.review.customer_review_workspace');
|
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)
|
$tenantIdentifier = filled($tenant->external_id)
|
||||||
? (string) $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;
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
@ -109,6 +116,7 @@ public function mount(): void
|
|||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
$this->applyRequestedTenantPrefilter();
|
$this->applyRequestedTenantPrefilter();
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
$this->auditWorkspaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
@ -146,37 +154,44 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
->recordUrl(null)
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
|
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable(),
|
||||||
TextColumn::make('latest_review')
|
TextColumn::make('package_availability')
|
||||||
->label(__('localization.review.latest_review'))
|
->label(__('localization.review.governance_package'))
|
||||||
|
->width('9rem')
|
||||||
|
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
->getStateUsing(fn (ManagedEnvironment $record): string => $this->governancePackageAvailabilityLabel($record))
|
||||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
->color(fn (ManagedEnvironment $record): string => $this->governancePackageAvailabilityColor($record))
|
||||||
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
|
->tooltip(fn (ManagedEnvironment $record): string => $this->governancePackageAvailability($record)['description']),
|
||||||
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
|
TextColumn::make('latest_review')
|
||||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
->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(),
|
->wrap(),
|
||||||
TextColumn::make('finding_summary')
|
TextColumn::make('open_review')
|
||||||
->label(__('localization.review.key_findings'))
|
->label(__('localization.review.open'))
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
->width('8rem')
|
||||||
->wrap(),
|
->getStateUsing(fn (): string => __('localization.review.open_review'))
|
||||||
TextColumn::make('accepted_risk_summary')
|
->url(fn (ManagedEnvironment $record): ?string => $this->latestReviewUrl($record))
|
||||||
->label(__('localization.review.accepted_risks'))
|
->color('primary'),
|
||||||
->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)),
|
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label(__('localization.review.tenant'))
|
->label(__('localization.review.tenant'))
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||||
@ -189,24 +204,12 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->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))),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
|
->emptyStateHeading(__('localization.review.no_released_customer_reviews'))
|
||||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||||
? __('localization.review.clear_filters_description')
|
? __('localization.review.clear_filters_description')
|
||||||
: __('localization.review.adjust_filters_description'))
|
: __('localization.review.no_released_customer_reviews_description'))
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
Action::make('clear_filters_empty')
|
Action::make('clear_filters_empty')
|
||||||
->label(__('localization.review.clear_filters'))
|
->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
|
public function authorizedTenants(): array
|
||||||
{
|
{
|
||||||
@ -233,7 +236,7 @@ public function authorizedTenants(): array
|
|||||||
return $this->authorizedTenants = [];
|
return $this->authorizedTenants = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
return $this->authorizedTenants = app(EnvironmentReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
private function authorizePageAccess(): void
|
||||||
@ -249,7 +252,7 @@ private function authorizePageAccess(): void
|
|||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service = app(TenantReviewRegisterService::class);
|
$service = app(EnvironmentReviewRegisterService::class);
|
||||||
|
|
||||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
@ -260,16 +263,44 @@ 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
|
private function workspaceQuery(): Builder
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$workspace = $this->workspace();
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof 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);
|
return app(EnvironmentReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -278,7 +309,7 @@ private function workspaceQuery(): Builder
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return collect($this->authorizedTenants())
|
return collect($this->authorizedTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
(string) $tenant->getKey() => $tenant->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -286,16 +317,12 @@ private function tenantFilterOptions(): array
|
|||||||
|
|
||||||
private function defaultTenantFilter(): ?string
|
private function defaultTenantFilter(): ?string
|
||||||
{
|
{
|
||||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
return null;
|
||||||
|
|
||||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
|
||||||
? (string) $tenantId
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
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)) {
|
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||||
return;
|
return;
|
||||||
@ -306,8 +333,8 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -322,16 +349,15 @@ private function hasActiveFilters(): bool
|
|||||||
|
|
||||||
private function clearWorkspaceFilters(): void
|
private function clearWorkspaceFilters(): void
|
||||||
{
|
{
|
||||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
|
||||||
$this->removeTableFilters();
|
$this->removeTableFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
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)) {
|
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;
|
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||||
@ -346,85 +372,57 @@ private function workspace(): ?Workspace
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestPublishedReview(Tenant $tenant): ?TenantReview
|
private function latestPublishedReview(ManagedEnvironment $tenant): ?EnvironmentReview
|
||||||
{
|
{
|
||||||
$review = $tenant->tenantReviews->first();
|
$review = $tenant->environmentReviews->first();
|
||||||
|
|
||||||
return $review instanceof TenantReview ? $review : null;
|
return $review instanceof EnvironmentReview ? $review : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewUrl(Tenant $tenant): ?string
|
private function latestReviewUrl(ManagedEnvironment $tenant): ?string
|
||||||
{
|
{
|
||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
if (! $review instanceof EnvironmentReview) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->appendQuery(
|
$query = array_filter(
|
||||||
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
|
|
||||||
array_replace(
|
array_replace(
|
||||||
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
||||||
|
[
|
||||||
|
'source_surface' => self::SOURCE_SURFACE,
|
||||||
|
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||||
|
],
|
||||||
$this->navigationContext()?->toQuery() ?? [],
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
),
|
),
|
||||||
|
static fn (mixed $value): bool => $value !== null && $value !== '',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return $this->appendQuery(EnvironmentReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant), $query);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
private function latestPublishedAt(ManagedEnvironment $tenant): ?\Illuminate\Support\Carbon
|
||||||
{
|
|
||||||
$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
|
|
||||||
{
|
{
|
||||||
return $this->latestPublishedReview($tenant)?->published_at;
|
return $this->latestPublishedReview($tenant)?->published_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
|
private function reviewTruth(ManagedEnvironment $tenant): ?ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
return $review instanceof TenantReview
|
return $review instanceof EnvironmentReview
|
||||||
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
|
? app(ArtifactTruthPresenter::class)->forEnvironmentReview($review)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
private function reviewOutcome(ManagedEnvironment $tenant): ?CompressedGovernanceOutcome
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
$truth = $this->reviewTruth($tenant);
|
$truth = $this->reviewTruth($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
|
if (! $review instanceof EnvironmentReview || ! $truth instanceof ArtifactTruthEnvelope) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,31 +430,53 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
|||||||
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
|
?? $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 EnvironmentReview) {
|
||||||
|
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 EnvironmentReview) {
|
||||||
|
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;
|
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewStateIconColor(Tenant $tenant): ?string
|
private function latestReviewStateIconColor(ManagedEnvironment $tenant): ?string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
|
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeDescription(Tenant $tenant): ?string
|
private function reviewOutcomeDescription(ManagedEnvironment $tenant): ?string
|
||||||
{
|
{
|
||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
if (! $review instanceof EnvironmentReview) {
|
||||||
return __('localization.review.no_published_review_available');
|
return __('localization.review.no_published_review_available');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -477,11 +497,347 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
|||||||
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
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);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
if (! $review instanceof EnvironmentReview) {
|
||||||
|
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 EnvironmentReview) {
|
||||||
|
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, [
|
||||||
|
EnvironmentReviewCompletenessState::Partial->value,
|
||||||
|
EnvironmentReviewCompletenessState::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 EnvironmentReview) {
|
||||||
|
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 EnvironmentReview) {
|
||||||
|
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 EnvironmentReview) {
|
||||||
|
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 EnvironmentReview) {
|
||||||
|
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(EnvironmentReview $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);
|
||||||
|
|
||||||
|
if (! $review instanceof EnvironmentReview) {
|
||||||
return __('localization.review.no_published_review_available');
|
return __('localization.review.no_published_review_available');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -504,11 +860,11 @@ private function findingSummary(Tenant $tenant): string
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function acceptedRiskSummary(Tenant $tenant): string
|
private function acceptedRiskSummary(ManagedEnvironment $tenant): string
|
||||||
{
|
{
|
||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
if (! $review instanceof EnvironmentReview) {
|
||||||
return __('localization.review.no_published_review_available');
|
return __('localization.review.no_published_review_available');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,31 +874,142 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
|||||||
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
||||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||||
|
|
||||||
return match (true) {
|
$countSummary = match (true) {
|
||||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
||||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
||||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
||||||
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
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) {
|
if (! $review instanceof EnvironmentReview) {
|
||||||
return __('localization.review.unavailable');
|
return __('localization.review.no_published_review_available');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
$snapshot = $review->evidenceSnapshot;
|
||||||
return __('localization.review.unavailable');
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
|
return __('localization.review.evidence_proof_absent');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||||
return __('localization.review.unavailable');
|
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(EnvironmentReviewRegisterService::class)
|
||||||
|
->latestPublishedQuery($user, $workspace)
|
||||||
|
->get()
|
||||||
|
->map(static fn (EnvironmentReview $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->environmentReviews()->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
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
|||||||
@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Reviews;
|
namespace App\Filament\Pages\Reviews;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\EnvironmentReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\TenantReview;
|
use App\Models\EnvironmentReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -19,7 +19,7 @@
|
|||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\EnvironmentReviewCompletenessState;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -61,7 +61,7 @@ class ReviewRegister extends Page implements HasTable
|
|||||||
protected string $view = 'filament.pages.reviews.review-register';
|
protected string $view = 'filament.pages.reviews.review-register';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, Tenant>|null
|
* @var array<int, ManagedEnvironment>|null
|
||||||
*/
|
*/
|
||||||
private ?array $authorizedTenants = null;
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
@ -112,15 +112,15 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
->recordUrl(fn (EnvironmentReview $record): string => EnvironmentReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
TextColumn::make('tenant.name')->label('ManagedEnvironment')->searchable(),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EnvironmentReviewStatus)),
|
||||||
TextColumn::make('outcome')
|
TextColumn::make('outcome')
|
||||||
->label('Outcome')
|
->label('Outcome')
|
||||||
->badge()
|
->badge()
|
||||||
@ -138,8 +138,8 @@ public function table(Table $table): Table
|
|||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -154,7 +154,7 @@ public function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
SelectFilter::make('completeness_state')
|
SelectFilter::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
->options(BadgeCatalog::options(BadgeDomain::EnvironmentReviewCompleteness, EnvironmentReviewCompletenessState::values())),
|
||||||
SelectFilter::make('published_state')
|
SelectFilter::make('published_state')
|
||||||
->label('Published state')
|
->label('Published state')
|
||||||
->options([
|
->options([
|
||||||
@ -174,11 +174,11 @@ public function table(Table $table): Table
|
|||||||
Action::make('export_executive_pack')
|
Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
->visible(fn (EnvironmentReview $record): bool => auth()->user() instanceof User
|
||||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
&& auth()->user()->can(Capabilities::ENVIRONMENT_REVIEW_MANAGE, $record->tenant)
|
||||||
&& in_array($record->status, ['ready', 'published'], true))
|
&& in_array($record->status, ['ready', 'published'], true))
|
||||||
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
|
->disabled(fn (EnvironmentReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
|
||||||
->tooltip(function (TenantReview $record): ?string {
|
->tooltip(function (EnvironmentReview $record): ?string {
|
||||||
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
|
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
|
||||||
|
|
||||||
if ((bool) ($decision['is_blocked'] ?? false)) {
|
if ((bool) ($decision['is_blocked'] ?? false)) {
|
||||||
@ -195,7 +195,7 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
->action(fn (EnvironmentReview $record): mixed => EnvironmentReviewResource::executeExport($record)),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No review records match this view')
|
->emptyStateHeading('No review records match this view')
|
||||||
@ -210,7 +210,7 @@ public function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function authorizedTenants(): array
|
public function authorizedTenants(): array
|
||||||
{
|
{
|
||||||
@ -225,7 +225,7 @@ public function authorizedTenants(): array
|
|||||||
return $this->authorizedTenants = [];
|
return $this->authorizedTenants = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
return $this->authorizedTenants = app(EnvironmentReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
private function authorizePageAccess(): void
|
||||||
@ -241,7 +241,7 @@ private function authorizePageAccess(): void
|
|||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service = app(TenantReviewRegisterService::class);
|
$service = app(EnvironmentReviewRegisterService::class);
|
||||||
|
|
||||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
@ -258,10 +258,10 @@ private function registerQuery(): Builder
|
|||||||
$workspace = $this->workspace();
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
return TenantReview::query()->whereRaw('1 = 0');
|
return EnvironmentReview::query()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
return app(EnvironmentReviewRegisterService::class)->query($user, $workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -270,7 +270,7 @@ private function registerQuery(): Builder
|
|||||||
private function tenantFilterOptions(): array
|
private function tenantFilterOptions(): array
|
||||||
{
|
{
|
||||||
return collect($this->authorizedTenants())
|
return collect($this->authorizedTenants())
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
(string) $tenant->getKey() => $tenant->name,
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -278,11 +278,7 @@ private function tenantFilterOptions(): array
|
|||||||
|
|
||||||
private function defaultTenantFilter(): ?string
|
private function defaultTenantFilter(): ?string
|
||||||
{
|
{
|
||||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
return null;
|
||||||
|
|
||||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
|
||||||
? (string) $tenantId
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
private function applyRequestedTenantPrefilter(): void
|
||||||
@ -298,8 +294,8 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -317,16 +313,15 @@ private function hasActiveFilters(): bool
|
|||||||
|
|
||||||
private function clearRegisterFilters(): void
|
private function clearRegisterFilters(): void
|
||||||
{
|
{
|
||||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
|
||||||
$this->removeTableFilters();
|
$this->removeTableFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
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)) {
|
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;
|
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||||
@ -341,36 +336,36 @@ private function workspace(): ?Workspace
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
private function reviewTruth(EnvironmentReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
return $fresh
|
return $fresh
|
||||||
? $presenter->forTenantReviewFresh($record)
|
? $presenter->forEnvironmentReviewFresh($record)
|
||||||
: $presenter->forTenantReview($record);
|
: $presenter->forEnvironmentReview($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeLabel(TenantReview $record): string
|
private function reviewOutcomeLabel(EnvironmentReview $record): string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($record)->primaryLabel;
|
return $this->reviewOutcome($record)->primaryLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeBadgeColor(TenantReview $record): string
|
private function reviewOutcomeBadgeColor(EnvironmentReview $record): string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($record)->primaryBadge->color;
|
return $this->reviewOutcome($record)->primaryBadge->color;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeBadgeIcon(TenantReview $record): ?string
|
private function reviewOutcomeBadgeIcon(EnvironmentReview $record): ?string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($record)->primaryBadge->icon;
|
return $this->reviewOutcome($record)->primaryBadge->icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
|
private function reviewOutcomeBadgeIconColor(EnvironmentReview $record): ?string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($record)->primaryBadge->iconColor;
|
return $this->reviewOutcome($record)->primaryBadge->iconColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeDescription(TenantReview $record): ?string
|
private function reviewOutcomeDescription(EnvironmentReview $record): ?string
|
||||||
{
|
{
|
||||||
$primaryReason = $this->reviewOutcome($record)->primaryReason;
|
$primaryReason = $this->reviewOutcome($record)->primaryReason;
|
||||||
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
|
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
|
||||||
@ -382,12 +377,12 @@ private function reviewOutcomeDescription(TenantReview $record): ?string
|
|||||||
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeNextStep(TenantReview $record): string
|
private function reviewOutcomeNextStep(EnvironmentReview $record): string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($record)->nextActionText;
|
return $this->reviewOutcome($record)->nextActionText;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome
|
private function reviewOutcome(EnvironmentReview $record, bool $fresh = false): CompressedGovernanceOutcome
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
$truth = $fresh
|
$truth = $fresh
|
||||||
@ -401,7 +396,7 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findingOutcomeSummary(TenantReview $record): ?string
|
private function findingOutcomeSummary(EnvironmentReview $record): ?string
|
||||||
{
|
{
|
||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
||||||
|
|||||||
@ -4,18 +4,23 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Settings;
|
namespace App\Filament\Pages\Settings;
|
||||||
|
|
||||||
|
use App\Models\SupportAccessGrant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
use App\Support\Ai\AiPolicyMode;
|
use App\Services\Auth\SupportAccessGrantManager;
|
||||||
use App\Support\Ai\AiUseCaseCatalog;
|
use App\Services\Auth\SupportAccessGrantResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
use App\Services\Localization\LocaleResolver;
|
use App\Services\Localization\LocaleResolver;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use App\Support\Ai\AiPolicyMode;
|
||||||
|
use App\Support\Ai\AiUseCaseCatalog;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
use App\Support\Settings\SettingDefinition;
|
use App\Support\Settings\SettingDefinition;
|
||||||
use App\Support\Settings\SettingsRegistry;
|
use App\Support\Settings\SettingsRegistry;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -141,6 +146,11 @@ class WorkspaceSettings extends Page
|
|||||||
*/
|
*/
|
||||||
public array $entitlementSummary = [];
|
public array $entitlementSummary = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $commercialLifecycleSummary = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
* 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())
|
->helperText(fn (): string => $this->localeDefaultHelperText())
|
||||||
->hintAction($this->makeResetAction('localization_default_locale')),
|
->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')
|
Section::make('Workspace entitlements')
|
||||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@ -653,6 +708,7 @@ private function loadFormState(): void
|
|||||||
$this->workspaceOverrides = $workspaceOverrides;
|
$this->workspaceOverrides = $workspaceOverrides;
|
||||||
$this->resolvedSettings = $resolvedSettings;
|
$this->resolvedSettings = $resolvedSettings;
|
||||||
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||||
|
$this->commercialLifecycleSummary = app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
|
||||||
|
|
||||||
$this->loadDomainLastModified();
|
$this->loadDomainLastModified();
|
||||||
}
|
}
|
||||||
@ -697,6 +753,125 @@ private function loadDomainLastModified(): void
|
|||||||
$this->domainLastModified = $domainInfo;
|
$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.
|
* 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';
|
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
|
private function helperTextFor(string $field): string
|
||||||
{
|
{
|
||||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||||
@ -1543,9 +1755,27 @@ private function currentUserCanManage(): bool
|
|||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
return $resolver->isMember($user, $this->workspace)
|
return $resolver->isMember($user, $this->workspace)
|
||||||
|
&& ! $this->workspace->isClosed()
|
||||||
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
&& $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
|
private function authorizeWorkspaceView(User $user): void
|
||||||
{
|
{
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
@ -1572,5 +1802,11 @@ private function authorizeWorkspaceManage(User $user): void
|
|||||||
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->workspace->isClosed()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'workspace' => 'This workspace is closed. Reopen it before changing workspace settings.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Tenancy;
|
namespace App\Filament\Pages\Tenancy;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Auth\ManagedEnvironmentMembershipManager;
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||||
@ -21,6 +20,14 @@ public static function getLabel(): string
|
|||||||
return 'Register tenant';
|
return 'Register tenant';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return class-string<Model>
|
||||||
|
*/
|
||||||
|
public function getModel(): string
|
||||||
|
{
|
||||||
|
return ManagedEnvironment::class;
|
||||||
|
}
|
||||||
|
|
||||||
public static function canView(): bool
|
public static function canView(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -43,21 +50,6 @@ public static function canView(): bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
|
||||||
|
|
||||||
if ($tenantIds->isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
|
||||||
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,8 +69,8 @@ public function form(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->default('other')
|
->default('other')
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\TextInput::make('tenant_id')
|
Forms\Components\TextInput::make('managed_environment_id')
|
||||||
->label('Tenant ID (GUID)')
|
->label('ManagedEnvironment ID (GUID)')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->unique(ignoreRecord: true),
|
->unique(ignoreRecord: true),
|
||||||
@ -104,36 +96,22 @@ protected function handleRegistration(array $data): Model
|
|||||||
$data['workspace_id'] = $workspaceId;
|
$data['workspace_id'] = $workspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::create($data);
|
$tenant = ManagedEnvironment::create($data);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if ($user instanceof User) {
|
if ($user instanceof User && is_int($workspaceId)) {
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$explicitScopes = app(ManagedEnvironmentAccessScopeResolver::class)
|
||||||
$tenant->getKey() => [
|
->allowedManagedEnvironmentIdsForWorkspace($user, $workspaceId);
|
||||||
'role' => 'owner',
|
|
||||||
'source' => 'manual',
|
|
||||||
'created_by_user_id' => $user->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
if (is_array($explicitScopes)) {
|
||||||
tenant: $tenant,
|
app(ManagedEnvironmentMembershipManager::class)->grantScope(
|
||||||
action: 'tenant_membership.bootstrap_assign',
|
tenant: $tenant,
|
||||||
context: [
|
actor: $user,
|
||||||
'metadata' => [
|
member: $user,
|
||||||
'user_id' => (int) $user->getKey(),
|
source: 'manual',
|
||||||
'role' => 'owner',
|
);
|
||||||
'source' => 'manual',
|
}
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: (int) $user->getKey(),
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
status: 'success',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->getKey(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,19 +4,20 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Workspaces;
|
namespace App\Filament\Pages\Workspaces;
|
||||||
|
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseEnvironment;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class ManagedTenantsLanding extends Page
|
class ManagedEnvironmentsLanding extends Page
|
||||||
{
|
{
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
@ -24,12 +25,15 @@ class ManagedTenantsLanding extends Page
|
|||||||
|
|
||||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||||
|
|
||||||
protected static ?string $title = 'Managed tenants';
|
protected string $view = 'filament.pages.workspaces.managed-environments-landing';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
|
|
||||||
|
|
||||||
public Workspace $workspace;
|
public Workspace $workspace;
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return __('localization.shell.managed_environments_title');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Filament simple layout renders the topbar by default, which includes
|
* The Filament simple layout renders the topbar by default, which includes
|
||||||
* lazy-loaded database notifications. On this workspace-scoped landing page,
|
* lazy-loaded database notifications. On this workspace-scoped landing page,
|
||||||
@ -48,26 +52,33 @@ public function mount(Workspace $workspace): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Tenant>
|
* @return Collection<int, ManagedEnvironment>
|
||||||
*/
|
*/
|
||||||
public function getTenants(): Collection
|
public function getTenants(): Collection
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
return ManagedEnvironment::query()->whereRaw('1 = 0')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantIds = $user->tenantMemberships()
|
/** @var CapabilityResolver $resolver */
|
||||||
->pluck('tenant_id');
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return Tenant::query()
|
$tenants = ManagedEnvironment::query()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->whereIn('id', $tenantIds)
|
|
||||||
->where('workspace_id', $this->workspace->getKey())
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get();
|
||||||
->filter(function (Tenant $tenant) use ($user): bool {
|
|
||||||
|
$resolver->primeMemberships($user, $tenants->modelKeys());
|
||||||
|
|
||||||
|
return $tenants
|
||||||
|
->filter(function (ManagedEnvironment $tenant) use ($resolver, $user): bool {
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return app(TenantOperabilityService::class)->outcomeFor(
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
@ -79,9 +90,9 @@ public function getTenants(): Collection
|
|||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function goToChooseTenant(): void
|
public function goToChooseEnvironment(): void
|
||||||
{
|
{
|
||||||
$this->redirect(ChooseTenant::getUrl());
|
$this->redirect(route('admin.workspace.managed-environments.index', ['workspace' => $this->workspace]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function openTenant(int $tenantId): void
|
public function openTenant(int $tenantId): void
|
||||||
@ -92,13 +103,13 @@ public function openTenant(int $tenantId): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::query()
|
$tenant = ManagedEnvironment::query()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->where('workspace_id', $this->workspace->getKey())
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
->whereKey($tenantId)
|
->whereKey($tenantId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +117,8 @@ public function openTenant(int $tenantId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
$this->redirect(
|
||||||
|
ManagedEnvironmentLinks::viewUrl($tenant)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -8,13 +8,13 @@
|
|||||||
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||||
use App\Models\AlertDelivery;
|
use App\Models\AlertDelivery;
|
||||||
use App\Models\AlertDestination;
|
use App\Models\AlertDestination;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -114,7 +114,8 @@ public static function getEloquentQuery(): Builder
|
|||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->with(['tenant', 'rule', 'destination'])
|
->with(['tenant', 'rule', 'destination'])
|
||||||
@ -132,15 +133,24 @@ public static function getEloquentQuery(): Builder
|
|||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$user instanceof User,
|
$user instanceof User,
|
||||||
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
|
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($scopeResolver, $user, $workspaceId): void {
|
||||||
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
|
$q->whereNull('managed_environment_id')
|
||||||
->orWhereNull('tenant_id');
|
->orWhere(function (Builder $scopedQuery) use ($scopeResolver, $user, $workspaceId): void {
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
$scopedQuery->whereRaw('1 = 0');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeResolver->applyWorkspaceScopeToQuery(
|
||||||
|
query: $scopedQuery,
|
||||||
|
user: $user,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
qualifiedEnvironmentColumn: 'alert_deliveries.managed_environment_id',
|
||||||
|
);
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->when(
|
|
||||||
$activeTenant instanceof Tenant,
|
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
|
||||||
)
|
|
||||||
->latest('id');
|
->latest('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,7 +179,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('tenant.name')
|
TextEntry::make('tenant.name')
|
||||||
->label('Tenant'),
|
->label('ManagedEnvironment'),
|
||||||
TextEntry::make('rule.name')
|
TextEntry::make('rule.name')
|
||||||
->label('Rule')
|
->label('Rule')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -230,7 +240,7 @@ public static function table(Table $table): Table
|
|||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('event_type')
|
TextColumn::make('event_type')
|
||||||
->label('Event')
|
->label('Event')
|
||||||
@ -257,17 +267,9 @@ public static function table(Table $table): Table
|
|||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
@ -275,26 +277,12 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
})
|
})
|
||||||
->default(function (): ?string {
|
->default(null)
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $activeTenant->getKey();
|
|
||||||
})
|
|
||||||
->searchable(),
|
->searchable(),
|
||||||
SelectFilter::make('status')
|
SelectFilter::make('status')
|
||||||
->options(FilterOptionCatalog::alertDeliveryStatuses()),
|
->options(FilterOptionCatalog::alertDeliveryStatuses()),
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
use App\Filament\Resources\AlertRuleResource\Pages;
|
use App\Filament\Resources\AlertRuleResource\Pages;
|
||||||
use App\Models\AlertDestination;
|
use App\Models\AlertDestination;
|
||||||
use App\Models\AlertRule;
|
use App\Models\AlertRule;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
@ -434,9 +434,9 @@ private static function tenantOptions(): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('workspace_id', $workspaceId)
|
->where('workspace_id', $workspaceId)
|
||||||
->where('status', 'active')
|
->where('lifecycle_status', ManagedEnvironment::STATUS_ACTIVE)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->pluck('name', 'id')
|
->pluck('name', 'id')
|
||||||
->all();
|
->all();
|
||||||
|
|||||||
@ -5,11 +5,12 @@
|
|||||||
use App\Exceptions\InvalidPolicyTypeException;
|
use App\Exceptions\InvalidPolicyTypeException;
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||||
use App\Jobs\RunBackupScheduleJob;
|
use App\Jobs\RunBackupScheduleJob;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules\SupportedPolicyTypesRule;
|
use App\Rules\SupportedPolicyTypesRule;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
@ -23,6 +24,7 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
@ -68,6 +70,7 @@ class BackupScheduleResource extends Resource
|
|||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static ?string $model = BackupSchedule::class;
|
protected static ?string $model = BackupSchedule::class;
|
||||||
|
|
||||||
@ -79,11 +82,8 @@ class BackupScheduleResource extends Resource
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
@ -92,7 +92,7 @@ public static function canViewAny(): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,7 +108,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +120,7 @@ public static function canView(Model $record): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($record instanceof BackupSchedule) {
|
if ($record instanceof BackupSchedule) {
|
||||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
return (int) $record->managed_environment_id === (int) $tenant->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -132,7 +132,7 @@ public static function canCreate(): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,7 +148,7 @@ public static function canEdit(Model $record): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,7 +164,7 @@ public static function canDelete(Model $record): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +180,7 @@ public static function canDeleteAny(): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -440,9 +440,9 @@ public static function table(Table $table): Table
|
|||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('No tenant selected')
|
->title('No environment selected')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -511,9 +511,9 @@ public static function table(Table $table): Table
|
|||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('No tenant selected')
|
->title('No environment selected')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -590,7 +590,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
if ($record->tenant instanceof ManagedEnvironment) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup_schedule.restored',
|
action: 'backup_schedule.restored',
|
||||||
@ -633,7 +633,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
if ($record->tenant instanceof ManagedEnvironment) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup_schedule.archived',
|
action: 'backup_schedule.archived',
|
||||||
@ -685,7 +685,7 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
if ($record->tenant instanceof ManagedEnvironment) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup_schedule.force_deleted',
|
action: 'backup_schedule.force_deleted',
|
||||||
@ -728,9 +728,9 @@ public static function table(Table $table): Table
|
|||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('No tenant selected')
|
->title('No environment selected')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -825,9 +825,9 @@ public static function table(Table $table): Table
|
|||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('No tenant selected')
|
->title('No environment selected')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -1062,7 +1062,7 @@ public static function ensurePolicyTypes(array $data): array
|
|||||||
|
|
||||||
public static function assignTenant(array $data): array
|
public static function assignTenant(array $data): array
|
||||||
{
|
{
|
||||||
$data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
$data['managed_environment_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
@ -37,12 +37,12 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
->modifyQueryUsing(fn (Builder $query) => $query->where('managed_environment_id', ManagedEnvironment::currentOrFail()->getKey()))
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
->recordUrl(function (OperationRun $record): string {
|
->recordUrl(function (OperationRun $record): string {
|
||||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||||
$tenant = Tenant::currentOrFail();
|
$tenant = ManagedEnvironment::currentOrFail();
|
||||||
|
|
||||||
return OperationRunLinks::view($record, $tenant);
|
return OperationRunLinks::view($record, $tenant);
|
||||||
})
|
})
|
||||||
@ -106,7 +106,7 @@ private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
|||||||
|
|
||||||
$resolvedRecord = $this->getOwnerRecord()
|
$resolvedRecord = $this->getOwnerRecord()
|
||||||
->operationRuns()
|
->operationRuns()
|
||||||
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
->where('managed_environment_id', ManagedEnvironment::currentOrFail()->getKey())
|
||||||
->whereKey($recordId)
|
->whereKey($recordId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Resources\BackupSetResource\Pages;
|
use App\Filament\Resources\BackupSetResource\Pages;
|
||||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
use App\Jobs\BulkBackupSetDeleteJob;
|
use App\Jobs\BulkBackupSetDeleteJob;
|
||||||
use App\Jobs\BulkBackupSetForceDeleteJob;
|
use App\Jobs\BulkBackupSetForceDeleteJob;
|
||||||
use App\Jobs\BulkBackupSetRestoreJob;
|
use App\Jobs\BulkBackupSetRestoreJob;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -25,6 +26,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -62,6 +64,7 @@ class BackupSetResource extends Resource
|
|||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static ?string $model = BackupSet::class;
|
protected static ?string $model = BackupSet::class;
|
||||||
|
|
||||||
@ -73,11 +76,8 @@ class BackupSetResource extends Resource
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
@ -96,7 +96,7 @@ public static function canViewAny(): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ public static function canCreate(): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,7 +352,7 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -369,7 +369,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_set.delete',
|
type: 'backup_set.delete',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
@ -422,7 +422,7 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,7 +439,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_set.restore',
|
type: 'backup_set.restore',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
@ -507,7 +507,7 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,7 +524,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_set.force_delete',
|
type: 'backup_set.force_delete',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
@ -651,7 +651,7 @@ private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextE
|
|||||||
*/
|
*/
|
||||||
public static function createBackupSet(array $data): BackupSet
|
public static function createBackupSet(array $data): BackupSet
|
||||||
{
|
{
|
||||||
/** @var Tenant $tenant */
|
/** @var ManagedEnvironment $tenant */
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
/** @var BackupService $service */
|
/** @var BackupService $service */
|
||||||
@ -846,7 +846,7 @@ private static function backupHealthContinuityAssessment(BackupSet $record): ?Te
|
|||||||
|
|
||||||
/** @var TenantBackupHealthResolver $resolver */
|
/** @var TenantBackupHealthResolver $resolver */
|
||||||
$resolver = app(TenantBackupHealthResolver::class);
|
$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()) {
|
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
@ -72,7 +72,7 @@ private function restoreAction(): Action
|
|||||||
$record->restore();
|
$record->restore();
|
||||||
$record->items()->withTrashed()->restore();
|
$record->items()->withTrashed()->restore();
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
if ($record->tenant instanceof ManagedEnvironment) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup.restored',
|
action: 'backup.restored',
|
||||||
@ -113,7 +113,7 @@ private function archiveAction(): Action
|
|||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
if ($record->tenant instanceof ManagedEnvironment) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup.deleted',
|
action: 'backup.deleted',
|
||||||
@ -162,7 +162,7 @@ private function forceDeleteAction(): Action
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
if ($record->tenant instanceof ManagedEnvironment) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup.force_deleted',
|
action: 'backup.force_deleted',
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -122,12 +122,12 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
$tenant = $backupSet->tenant ?? ManagedEnvironment::current();
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
if ((int) $tenant->getKey() !== (int) $backupSet->managed_environment_id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,12 +201,12 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
$tenant = $backupSet->tenant ?? ManagedEnvironment::current();
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
if ((int) $tenant->getKey() !== (int) $backupSet->managed_environment_id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -477,7 +477,7 @@ private function backupItemInspectUrl(BackupItem $record): ?string
|
|||||||
|
|
||||||
$resolvedRecord = $backupSet->items()
|
$resolvedRecord = $backupSet->items()
|
||||||
->with(['policy', 'policyVersion', 'policyVersion.policy'])
|
->with(['policy', 'policyVersion', 'policyVersion.policy'])
|
||||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
->where('managed_environment_id', (int) $backupSet->managed_environment_id)
|
||||||
->whereKey($resolvedId)
|
->whereKey($resolvedId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -485,9 +485,9 @@ private function backupItemInspectUrl(BackupItem $record): ?string
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
$tenant = $backupSet->tenant ?? ManagedEnvironment::current();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -516,7 +516,7 @@ private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $rec
|
|||||||
}
|
}
|
||||||
|
|
||||||
$resolvedId = $backupSet->items()
|
$resolvedId = $backupSet->items()
|
||||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
->where('managed_environment_id', (int) $backupSet->managed_environment_id)
|
||||||
->whereKey($recordId)
|
->whereKey($recordId)
|
||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
@ -545,7 +545,7 @@ private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
$resolvedIds = $backupSet->items()
|
$resolvedIds = $backupSet->items()
|
||||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
->where('managed_environment_id', (int) $backupSet->managed_environment_id)
|
||||||
->whereIn('id', $requestedIds)
|
->whereIn('id', $requestedIds)
|
||||||
->pluck('id')
|
->pluck('id')
|
||||||
->map(fn (mixed $value): int => (int) $value)
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
@ -30,6 +30,7 @@
|
|||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
@ -96,7 +97,8 @@ public static function shouldRegisterNavigation(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
@ -465,7 +467,7 @@ public static function table(Table $table): Table
|
|||||||
->label('Version')
|
->label('Version')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('tenant_assignments_count')
|
TextColumn::make('tenant_assignments_count')
|
||||||
->label('Assigned tenants')
|
->label('Assigned environments')
|
||||||
->counts('tenantAssignments'),
|
->counts('tenantAssignments'),
|
||||||
TextColumn::make('current_snapshot_truth')
|
TextColumn::make('current_snapshot_truth')
|
||||||
->label('Current snapshot')
|
->label('Current snapshot')
|
||||||
@ -968,7 +970,7 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
|||||||
$tenantIds = BaselineTenantAssignment::query()
|
$tenantIds = BaselineTenantAssignment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->pluck('tenant_id')
|
->pluck('managed_environment_id')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
if ($tenantIds === []) {
|
||||||
@ -977,11 +979,11 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
|||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->whereIn('id', $tenantIds)
|
->whereIn('id', $tenantIds)
|
||||||
->get(['id'])
|
->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\Filament\Resources\BaselineProfileResource;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
@ -64,8 +64,8 @@ private function captureAction(): Action
|
|||||||
: 'Capture baseline';
|
: 'Capture baseline';
|
||||||
|
|
||||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||||
? 'Select the source tenant. This will capture content evidence on demand (redacted) and may take longer depending on scope.'
|
? 'Select the source environment. This will capture content evidence on demand (redacted) and may take longer depending on scope.'
|
||||||
: 'Select the source tenant whose current inventory will be captured as the baseline snapshot.';
|
: 'Select the source environment whose current inventory will be captured as the baseline snapshot.';
|
||||||
|
|
||||||
$action = Action::make('capture')
|
$action = Action::make('capture')
|
||||||
->label($label)
|
->label($label)
|
||||||
@ -76,8 +76,8 @@ private function captureAction(): Action
|
|||||||
->modalHeading($label)
|
->modalHeading($label)
|
||||||
->modalDescription($modalDescription)
|
->modalDescription($modalDescription)
|
||||||
->form([
|
->form([
|
||||||
Select::make('source_tenant_id')
|
Select::make('source_environment_id')
|
||||||
->label('Source Tenant')
|
->label('Source managed environment')
|
||||||
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -91,11 +91,11 @@ private function captureAction(): Action
|
|||||||
|
|
||||||
/** @var BaselineProfile $profile */
|
/** @var BaselineProfile $profile */
|
||||||
$profile = $this->getRecord();
|
$profile = $this->getRecord();
|
||||||
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
|
$sourceTenant = ManagedEnvironment::query()->find((int) $data['source_environment_id']);
|
||||||
|
|
||||||
if (! $sourceTenant instanceof Tenant) {
|
if (! $sourceTenant instanceof ManagedEnvironment) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Source tenant not found')
|
->title('Source environment not found')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -175,8 +175,8 @@ private function compareNowAction(): Action
|
|||||||
: 'Compare now';
|
: 'Compare now';
|
||||||
|
|
||||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
? 'Select the target environment. This will refresh content evidence on demand (redacted) before comparing.'
|
||||||
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
|
: 'Select the target environment to compare its current inventory against the effective current baseline snapshot.';
|
||||||
|
|
||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
@ -187,8 +187,8 @@ private function compareNowAction(): Action
|
|||||||
->modalHeading($label)
|
->modalHeading($label)
|
||||||
->modalDescription($modalDescription)
|
->modalDescription($modalDescription)
|
||||||
->form([
|
->form([
|
||||||
Select::make('target_tenant_id')
|
Select::make('target_environment_id')
|
||||||
->label('Target Tenant')
|
->label('Target managed environment')
|
||||||
->options(fn (): array => $this->getEligibleCompareTenantOptions())
|
->options(fn (): array => $this->getEligibleCompareTenantOptions())
|
||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -204,11 +204,11 @@ private function compareNowAction(): Action
|
|||||||
/** @var BaselineProfile $profile */
|
/** @var BaselineProfile $profile */
|
||||||
$profile = $this->getRecord();
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
$targetTenant = Tenant::query()->find((int) $data['target_tenant_id']);
|
$targetTenant = ManagedEnvironment::query()->find((int) $data['target_environment_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()
|
Notification::make()
|
||||||
->title('Target tenant not found')
|
->title('Target environment not found')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -217,13 +217,13 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
$assignment = BaselineTenantAssignment::query()
|
$assignment = BaselineTenantAssignment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->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())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Tenant not assigned')
|
->title('ManagedEnvironment not assigned')
|
||||||
->body('This tenant is not assigned to this baseline profile.')
|
->body('This tenant is not assigned to this baseline profile.')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
@ -311,11 +311,11 @@ private function compareNowAction(): Action
|
|||||||
private function compareAssignedTenantsAction(): Action
|
private function compareAssignedTenantsAction(): Action
|
||||||
{
|
{
|
||||||
$action = Action::make('compareAssignedTenants')
|
$action = Action::make('compareAssignedTenants')
|
||||||
->label('Compare assigned tenants')
|
->label('Compare assigned environments')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Compare assigned tenants')
|
->modalHeading('Compare assigned environments')
|
||||||
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
|
->modalDescription('Simulation only. This starts the normal environment-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
|
||||||
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
|
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
|
||||||
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
|
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
@ -384,7 +384,7 @@ private function getWorkspaceTenantOptions(): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('workspace_id', $workspaceId)
|
->where('workspace_id', $workspaceId)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->pluck('name', 'id')
|
->pluck('name', 'id')
|
||||||
@ -408,7 +408,7 @@ private function getEligibleCompareTenantOptions(): array
|
|||||||
$tenantIds = BaselineTenantAssignment::query()
|
$tenantIds = BaselineTenantAssignment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->pluck('tenant_id')
|
->pluck('managed_environment_id')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
if ($tenantIds === []) {
|
||||||
@ -419,14 +419,14 @@ private function getEligibleCompareTenantOptions(): array
|
|||||||
|
|
||||||
$options = [];
|
$options = [];
|
||||||
|
|
||||||
$tenants = Tenant::query()
|
$tenants = ManagedEnvironment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->whereIn('id', $tenantIds)
|
->whereIn('id', $tenantIds)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name']);
|
->get(['id', 'name']);
|
||||||
|
|
||||||
foreach ($tenants as $tenant) {
|
foreach ($tenants as $tenant) {
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -489,11 +489,11 @@ private function compareAssignedTenantsDisabledReason(): ?string
|
|||||||
$profile = $this->getRecord();
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
if (! $this->profileHasConsumableSnapshot()) {
|
if (! $this->profileHasConsumableSnapshot()) {
|
||||||
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
|
return 'Capture a complete baseline snapshot before comparing assigned environments.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->visibleAssignedTenantCount($profile) === 0) {
|
if ($this->visibleAssignedTenantCount($profile) === 0) {
|
||||||
return 'No visible assigned tenants are available for compare.';
|
return 'No visible assigned environments are available for compare.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -510,7 +510,7 @@ private function visibleAssignedTenantCount(BaselineProfile $profile): int
|
|||||||
$tenantIds = BaselineTenantAssignment::query()
|
$tenantIds = BaselineTenantAssignment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->pluck('tenant_id')
|
->pluck('managed_environment_id')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
if ($tenantIds === []) {
|
||||||
@ -519,11 +519,11 @@ private function visibleAssignedTenantCount(BaselineProfile $profile): int
|
|||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->whereIn('id', $tenantIds)
|
->whereIn('id', $tenantIds)
|
||||||
->get(['id'])
|
->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();
|
->count();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
@ -27,7 +27,7 @@ class BaselineTenantAssignmentsRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
protected static string $relationship = 'tenantAssignments';
|
protected static string $relationship = 'tenantAssignments';
|
||||||
|
|
||||||
protected static ?string $title = 'Tenant assignments';
|
protected static ?string $title = 'Environment assignments';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, array{baseline_profile_id:int, baseline_profile_name:string}>|null
|
* @var array<int, array{baseline_profile_id:int, baseline_profile_name:string}>|null
|
||||||
@ -37,11 +37,11 @@ class BaselineTenantAssignmentsRelationManager extends RelationManager
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign environment (manage-gated).')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.');
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning an environment.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
@ -51,7 +51,7 @@ public function table(Table $table): Table
|
|||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('tenant.name')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Managed environment')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('assignedByUser.name')
|
Tables\Columns\TextColumn::make('assignedByUser.name')
|
||||||
->label('Assigned by')
|
->label('Assigned by')
|
||||||
@ -68,8 +68,8 @@ public function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
$this->removeAssignmentAction(),
|
$this->removeAssignmentAction(),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No tenants assigned')
|
->emptyStateHeading('No environments assigned')
|
||||||
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
|
->emptyStateDescription('Assign an environment to compare its state against this baseline profile.')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
$this->assignTenantAction()->name('assignEmpty'),
|
$this->assignTenantAction()->name('assignEmpty'),
|
||||||
]);
|
]);
|
||||||
@ -78,12 +78,12 @@ public function table(Table $table): Table
|
|||||||
private function assignTenantAction(): Action
|
private function assignTenantAction(): Action
|
||||||
{
|
{
|
||||||
return Action::make('assign')
|
return Action::make('assign')
|
||||||
->label('Assign Tenant')
|
->label('Assign environment')
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
->visible(fn (): bool => $this->hasManageCapability())
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
->form([
|
->form([
|
||||||
Select::make('tenant_id')
|
Select::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('Managed environment')
|
||||||
->options(fn (): array => $this->getTenantOptions())
|
->options(fn (): array => $this->getTenantOptions())
|
||||||
->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries()))
|
->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries()))
|
||||||
->required()
|
->required()
|
||||||
@ -103,21 +103,21 @@ private function assignTenantAction(): Action
|
|||||||
|
|
||||||
/** @var BaselineProfile $profile */
|
/** @var BaselineProfile $profile */
|
||||||
$profile = $this->getOwnerRecord();
|
$profile = $this->getOwnerRecord();
|
||||||
$tenantId = (int) $data['tenant_id'];
|
$tenantId = (int) $data['managed_environment_id'];
|
||||||
|
|
||||||
$existing = BaselineTenantAssignment::query()
|
$existing = BaselineTenantAssignment::query()
|
||||||
->where('workspace_id', $profile->workspace_id)
|
->where('workspace_id', $profile->workspace_id)
|
||||||
->where('tenant_id', $tenantId)
|
->where('managed_environment_id', $tenantId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof BaselineTenantAssignment) {
|
if ($existing instanceof BaselineTenantAssignment) {
|
||||||
$assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId);
|
$assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Tenant already assigned')
|
->title('Environment already assigned')
|
||||||
->body($assignedBaselineName === null
|
->body($assignedBaselineName === null
|
||||||
? 'This tenant already has a baseline assignment in this workspace.'
|
? 'This environment already has a baseline assignment in this workspace.'
|
||||||
: "This tenant is already assigned to baseline: {$assignedBaselineName}.")
|
: "This environment is already assigned to baseline: {$assignedBaselineName}.")
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ private function assignTenantAction(): Action
|
|||||||
|
|
||||||
$assignment = BaselineTenantAssignment::create([
|
$assignment = BaselineTenantAssignment::create([
|
||||||
'workspace_id' => (int) $profile->workspace_id,
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
'tenant_id' => $tenantId,
|
'managed_environment_id' => $tenantId,
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'assigned_by_user_id' => (int) $user->getKey(),
|
'assigned_by_user_id' => (int) $user->getKey(),
|
||||||
]);
|
]);
|
||||||
@ -134,7 +134,7 @@ private function assignTenantAction(): Action
|
|||||||
$this->auditAssignment($profile, $assignment, $user, 'created');
|
$this->auditAssignment($profile, $assignment, $user, 'created');
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Tenant assigned')
|
->title('Environment assigned')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -150,8 +150,8 @@ private function removeAssignmentAction(): Action
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (): bool => $this->hasManageCapability())
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Remove tenant assignment')
|
->modalHeading('Remove environment assignment')
|
||||||
->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
|
->modalDescription('Are you sure you want to remove this environment assignment? This will not delete any existing findings.')
|
||||||
->action(function (BaselineTenantAssignment $record): void {
|
->action(function (BaselineTenantAssignment $record): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -190,11 +190,11 @@ private function getTenantOptions(): array
|
|||||||
|
|
||||||
$assignmentSummaries = $this->getTenantAssignmentSummaries();
|
$assignmentSummaries = $this->getTenantAssignmentSummaries();
|
||||||
|
|
||||||
return Tenant::query()
|
return ManagedEnvironment::query()
|
||||||
->where('workspace_id', $profile->workspace_id)
|
->where('workspace_id', $profile->workspace_id)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name'])
|
->get(['id', 'name'])
|
||||||
->mapWithKeys(function (Tenant $tenant) use ($assignmentSummaries): array {
|
->mapWithKeys(function (ManagedEnvironment $tenant) use ($assignmentSummaries): array {
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
$assignmentSummary = $assignmentSummaries[$tenantId] ?? null;
|
$assignmentSummary = $assignmentSummaries[$tenantId] ?? null;
|
||||||
|
|
||||||
@ -220,12 +220,12 @@ private function getTenantAssignmentSummaries(): array
|
|||||||
$this->tenantAssignmentSummaries = BaselineTenantAssignment::query()
|
$this->tenantAssignmentSummaries = BaselineTenantAssignment::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->with('baselineProfile:id,name')
|
->with('baselineProfile:id,name')
|
||||||
->get(['tenant_id', 'baseline_profile_id'])
|
->get(['managed_environment_id', 'baseline_profile_id'])
|
||||||
->mapWithKeys(function (BaselineTenantAssignment $assignment): array {
|
->mapWithKeys(function (BaselineTenantAssignment $assignment): array {
|
||||||
$baselineProfile = $assignment->baselineProfile;
|
$baselineProfile = $assignment->baselineProfile;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
(int) $assignment->tenant_id => [
|
(int) $assignment->managed_environment_id => [
|
||||||
'baseline_profile_id' => (int) $assignment->baseline_profile_id,
|
'baseline_profile_id' => (int) $assignment->baseline_profile_id,
|
||||||
'baseline_profile_name' => $baselineProfile instanceof BaselineProfile
|
'baseline_profile_name' => $baselineProfile instanceof BaselineProfile
|
||||||
? (string) $baselineProfile->name
|
? (string) $baselineProfile->name
|
||||||
@ -242,7 +242,7 @@ private function getTenantAssignmentSummaries(): array
|
|||||||
* @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary
|
* @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary
|
||||||
*/
|
*/
|
||||||
private function formatTenantOptionLabel(
|
private function formatTenantOptionLabel(
|
||||||
Tenant $tenant,
|
ManagedEnvironment $tenant,
|
||||||
?array $assignmentSummary,
|
?array $assignmentSummary,
|
||||||
): string {
|
): string {
|
||||||
$tenantName = (string) $tenant->name;
|
$tenantName = (string) $tenant->name;
|
||||||
@ -282,7 +282,7 @@ private function auditAssignment(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::query()->find($assignment->tenant_id);
|
$tenant = ManagedEnvironment::query()->find($assignment->managed_environment_id);
|
||||||
|
|
||||||
$auditLogger = app(WorkspaceAuditLogger::class);
|
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||||
|
|
||||||
@ -292,8 +292,8 @@ private function auditAssignment(
|
|||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'baseline_profile_name' => (string) $profile->name,
|
'baseline_profile_name' => (string) $profile->name,
|
||||||
'tenant_id' => (int) $assignment->tenant_id,
|
'managed_environment_id' => (int) $assignment->managed_environment_id,
|
||||||
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
|
'tenant_name' => $tenant instanceof ManagedEnvironment ? (string) $tenant->display_name : '—',
|
||||||
],
|
],
|
||||||
actor: $user,
|
actor: $user,
|
||||||
resourceType: 'baseline_profile',
|
resourceType: 'baseline_profile',
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -67,7 +68,8 @@ public static function shouldRegisterNavigation(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
|
|||||||
@ -5,12 +5,14 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -37,6 +39,7 @@ class EntraGroupResource extends Resource
|
|||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
use ScopesGlobalSearchToTenant;
|
use ScopesGlobalSearchToTenant;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
@ -54,11 +57,8 @@ class EntraGroupResource extends Resource
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
@ -187,7 +187,7 @@ public static function table(Table $table): Table
|
|||||||
->actions([])
|
->actions([])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No groups cached yet')
|
->emptyStateHeading('No groups cached yet')
|
||||||
->emptyStateDescription('Sync groups for the current tenant to browse directory data here.')
|
->emptyStateDescription('No groups are available for this managed environment yet. Run or refresh the relevant directory inventory operation to make groups visible here.')
|
||||||
->emptyStateIcon('heroicon-o-user-group');
|
->emptyStateIcon('heroicon-o-user-group');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +204,7 @@ public static function resolveScopedRecordOrFail(int|string $key): Model
|
|||||||
|
|
||||||
public static function getGlobalSearchResultUrl(Model $record): string
|
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
|
? $record->tenant
|
||||||
: static::panelTenantContext();
|
: static::panelTenantContext();
|
||||||
|
|
||||||
@ -225,7 +225,7 @@ public static function getPages(): array
|
|||||||
public static function scopedUrl(
|
public static function scopedUrl(
|
||||||
string $page = 'index',
|
string $page = 'index',
|
||||||
array $parameters = [],
|
array $parameters = [],
|
||||||
?Tenant $tenant = null,
|
?ManagedEnvironment $tenant = null,
|
||||||
?string $panel = null,
|
?string $panel = null,
|
||||||
): string {
|
): string {
|
||||||
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Directory\EntraGroupSyncService;
|
use App\Services\Directory\EntraGroupSyncService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -30,7 +30,7 @@ public function mount(): void
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||||
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
|
&& ! EntraGroupResource::panelTenantContext() instanceof ManagedEnvironment
|
||||||
) {
|
) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
@ -47,7 +47,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Operations')
|
->label('Operations')
|
||||||
->icon('heroicon-o-clock')
|
->icon('heroicon-o-clock')
|
||||||
->url(fn (): string => OperationRunLinks::index($tenant))
|
->url(fn (): string => OperationRunLinks::index($tenant))
|
||||||
->visible(fn (): bool => $tenant instanceof Tenant),
|
->visible(fn (): bool => $tenant instanceof ManagedEnvironment),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('sync_groups')
|
Action::make('sync_groups')
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
@ -57,7 +57,7 @@ protected function getHeaderActions(): array
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = EntraGroupResource::panelTenantContext();
|
$tenant = EntraGroupResource::panelTenantContext();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@ -27,16 +27,16 @@ protected function authorizeAccess(): void
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||||
&& ! $tenant instanceof Tenant
|
&& ! $tenant instanceof ManagedEnvironment
|
||||||
) {
|
) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof EntraGroup) {
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $record instanceof EntraGroup) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,29 +6,33 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
use App\Filament\Resources\EnvironmentReviewResource\Pages;
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\TenantReview;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\TenantReviewSection;
|
use App\Models\EnvironmentReview;
|
||||||
|
use App\Models\EnvironmentReviewSection;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\ReviewPackStatus;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\EnvironmentReviewCompletenessState;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\EnvironmentReviewStatus;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -46,6 +50,7 @@
|
|||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Panel;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -57,14 +62,15 @@
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class TenantReviewResource extends Resource
|
class EnvironmentReviewResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static ?string $model = TenantReview::class;
|
protected static ?string $model = EnvironmentReview::class;
|
||||||
|
|
||||||
protected static ?string $slug = 'reviews';
|
protected static ?string $slug = 'reviews';
|
||||||
|
|
||||||
@ -82,7 +88,17 @@ class TenantReviewResource extends Resource
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
|
&& parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSlug(?Panel $panel = null): string
|
||||||
|
{
|
||||||
|
$slug = $panel?->getId() === 'admin'
|
||||||
|
? 'environment-reviews'
|
||||||
|
: parent::getSlug($panel);
|
||||||
|
|
||||||
|
return static::workspaceScopedSlug($slug, $panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getNavigationGroup(): string
|
public static function getNavigationGroup(): string
|
||||||
@ -110,7 +126,7 @@ public static function canViewAny(): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +134,7 @@ public static function canViewAny(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant);
|
return $user->can(Capabilities::ENVIRONMENT_REVIEW_VIEW, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
@ -126,7 +142,7 @@ public static function canView(Model $record): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $record instanceof EnvironmentReview) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +150,7 @@ public static function canView(Model $record): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +163,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
->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.')
|
->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.');
|
->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.');
|
||||||
}
|
}
|
||||||
@ -178,7 +194,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
ViewEntry::make('artifact_truth')
|
ViewEntry::make('artifact_truth')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->view('filament.infolists.entries.governance-artifact-truth')
|
->view('filament.infolists.entries.governance-artifact-truth')
|
||||||
->state(fn (TenantReview $record): array => static::truthState($record))
|
->state(fn (EnvironmentReview $record): array => static::truthState($record))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
@ -186,35 +202,36 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EnvironmentReviewStatus)),
|
||||||
TextEntry::make('completeness_state')
|
TextEntry::make('completeness_state')
|
||||||
->label(__('localization.review.completeness'))
|
->label(__('localization.review.completeness'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewCompleteness))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewCompleteness))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
->icon(BadgeRenderer::icon(BadgeDomain::EnvironmentReviewCompleteness))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EnvironmentReviewCompleteness)),
|
||||||
TextEntry::make('tenant.name')->label(__('localization.review.tenant')),
|
TextEntry::make('tenant.name')->label(__('localization.review.tenant')),
|
||||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('evidenceSnapshot.id')
|
TextEntry::make('evidenceSnapshot.id')
|
||||||
->label(__('localization.review.evidence_snapshot'))
|
->label(__('localization.review.evidence_snapshot'))
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
->url(fn (EnvironmentReview $record): ?string => $record->evidenceSnapshot
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||||
: null),
|
: null),
|
||||||
TextEntry::make('currentExportReviewPack.id')
|
TextEntry::make('currentExportReviewPack.id')
|
||||||
->label(__('localization.review.current_export'))
|
->label(__('localization.review.current_export'))
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
->url(fn (EnvironmentReview $record): ?string => $record->currentExportReviewPack
|
||||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
||||||
: null),
|
: null),
|
||||||
TextEntry::make('fingerprint')
|
TextEntry::make('fingerprint')
|
||||||
->copyable()
|
->copyable()
|
||||||
->placeholder('—')
|
->placeholder('—')
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceMode())
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->fontFamily('mono')
|
->fontFamily('mono')
|
||||||
->size(TextSize::ExtraSmall),
|
->size(TextSize::ExtraSmall),
|
||||||
@ -225,31 +242,32 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('review_summary')
|
ViewEntry::make('review_summary')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->view('filament.infolists.entries.tenant-review-summary')
|
->view('filament.infolists.entries.environment-review-summary')
|
||||||
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
|
->state(fn (EnvironmentReview $record): array => static::summaryPresentation($record))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make(__('localization.review.sections'))
|
Section::make(__('localization.review.sections'))
|
||||||
->schema([
|
->schema([
|
||||||
RepeatableEntry::make('sections')
|
RepeatableEntry::make('sections')
|
||||||
|
->state(fn (EnvironmentReview $record): array => static::visibleSections($record))
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('title'),
|
TextEntry::make('title'),
|
||||||
TextEntry::make('completeness_state')
|
TextEntry::make('completeness_state')
|
||||||
->label(__('localization.review.completeness'))
|
->label(__('localization.review.completeness'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewCompleteness))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewCompleteness))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
->icon(BadgeRenderer::icon(BadgeDomain::EnvironmentReviewCompleteness))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EnvironmentReviewCompleteness)),
|
||||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
||||||
Section::make(__('localization.review.details'))
|
Section::make(__('localization.review.details'))
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('section_payload')
|
ViewEntry::make('section_payload')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->view('filament.infolists.entries.tenant-review-section')
|
->view('filament.infolists.entries.environment-review-section')
|
||||||
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
|
->state(fn (EnvironmentReviewSection $record): array => static::sectionPresentation($record))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->collapsible()
|
->collapsible()
|
||||||
@ -262,49 +280,60 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, EnvironmentReviewSection>
|
||||||
|
*/
|
||||||
|
private static function visibleSections(EnvironmentReview $record): array
|
||||||
|
{
|
||||||
|
return $record->sections
|
||||||
|
->reject(fn (EnvironmentReviewSection $section): bool => static::isCustomerWorkspaceMode() && $section->isControlInterpretation())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label(__('localization.review.export_executive_pack'))
|
->label(__('localization.review.export_executive_pack'))
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
->visible(fn (EnvironmentReview $record): bool => in_array($record->status, [
|
||||||
TenantReviewStatus::Ready->value,
|
EnvironmentReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
EnvironmentReviewStatus::Published->value,
|
||||||
], true))
|
], true))
|
||||||
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
|
->disabled(fn (EnvironmentReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
|
||||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
->action(fn (EnvironmentReview $record): mixed => static::executeExport($record)),
|
||||||
fn (TenantReview $record): TenantReview => $record,
|
fn (EnvironmentReview $record): EnvironmentReview => $record,
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->preserveDisabled()
|
->preserveDisabled()
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
|
$exportExecutivePackAction->tooltip(fn (EnvironmentReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('generated_at', 'desc')
|
->defaultSort('generated_at', 'desc')
|
||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
->recordUrl(fn (EnvironmentReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EnvironmentReviewStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
->label(__('localization.review.outcome'))
|
->label(__('localization.review.outcome'))
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
|
->getStateUsing(fn (EnvironmentReview $record): string => static::compressedOutcome($record)->primaryLabel)
|
||||||
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
|
->color(fn (EnvironmentReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
|
||||||
->icon(fn (TenantReview $record): ?string => static::compressedOutcome($record)->primaryBadge->icon)
|
->icon(fn (EnvironmentReview $record): ?string => static::compressedOutcome($record)->primaryBadge->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => static::compressedOutcome($record)->primaryBadge->iconColor)
|
->iconColor(fn (EnvironmentReview $record): ?string => static::compressedOutcome($record)->primaryBadge->iconColor)
|
||||||
->description(fn (TenantReview $record): ?string => static::compressedOutcome($record)->primaryReason)
|
->description(fn (EnvironmentReview $record): ?string => static::compressedOutcome($record)->primaryReason)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
@ -313,7 +342,7 @@ public static function table(Table $table): Table
|
|||||||
->boolean(),
|
->boolean(),
|
||||||
Tables\Columns\TextColumn::make('next_step')
|
Tables\Columns\TextColumn::make('next_step')
|
||||||
->label(__('localization.review.next_step'))
|
->label(__('localization.review.next_step'))
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
|
->getStateUsing(fn (EnvironmentReview $record): string => static::compressedOutcome($record)->nextActionText)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
@ -321,18 +350,18 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options(collect(TenantReviewStatus::cases())
|
->options(collect(EnvironmentReviewStatus::cases())
|
||||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
->mapWithKeys(fn (EnvironmentReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
||||||
->all()),
|
->all()),
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
Tables\Filters\SelectFilter::make('completeness_state')
|
||||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
->options(BadgeCatalog::options(BadgeDomain::EnvironmentReviewCompleteness, EnvironmentReviewCompletenessState::values())),
|
||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
$exportExecutivePackAction,
|
$exportExecutivePackAction,
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
|
->emptyStateHeading(__('localization.review.no_environment_reviews_yet'))
|
||||||
->emptyStateDescription(__('localization.review.create_first_review_description'))
|
->emptyStateDescription(__('localization.review.create_first_review_description'))
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
static::makeCreateReviewAction(
|
static::makeCreateReviewAction(
|
||||||
@ -346,8 +375,8 @@ public static function table(Table $table): Table
|
|||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => Pages\ListTenantReviews::route('/'),
|
'index' => Pages\ListEnvironmentReviews::route('/'),
|
||||||
'view' => Pages\ViewTenantReview::route('/{record}'),
|
'view' => Pages\ViewEnvironmentReview::route('/{record}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,7 +406,7 @@ public static function makeCreateReviewAction(
|
|||||||
])
|
])
|
||||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -386,10 +415,10 @@ public static function makeCreateReviewAction(
|
|||||||
*/
|
*/
|
||||||
public static function executeCreateReview(array $data): void
|
public static function executeCreateReview(array $data): void
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$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();
|
Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -399,7 +428,7 @@ public static function executeCreateReview(array $data): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
if (! $user->can(Capabilities::ENVIRONMENT_REVIEW_MANAGE, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,7 +436,7 @@ public static function executeCreateReview(array $data): void
|
|||||||
$snapshot = is_numeric($snapshotId)
|
$snapshot = is_numeric($snapshotId)
|
||||||
? EvidenceSnapshot::query()
|
? EvidenceSnapshot::query()
|
||||||
->whereKey((int) $snapshotId)
|
->whereKey((int) $snapshotId)
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
->first()
|
->first()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -418,7 +447,7 @@ public static function executeCreateReview(array $data): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
$review = app(EnvironmentReviewService::class)->create($tenant, $snapshot, $user);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
@ -442,7 +471,7 @@ public static function executeCreateReview(array $data): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
$toast = OperationUxPresenter::queuedToast(OperationRunType::EnvironmentReviewCompose->value)
|
||||||
->body(__('localization.review.review_composing_background'));
|
->body(__('localization.review.review_composing_background'));
|
||||||
|
|
||||||
if ($review->operation_run_id) {
|
if ($review->operation_run_id) {
|
||||||
@ -459,23 +488,23 @@ public static function executeCreateReview(array $data): void
|
|||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
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);
|
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);
|
$decision = static::reviewPackGenerationDecision($tenant);
|
||||||
|
|
||||||
@ -488,7 +517,7 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
|
|||||||
return is_string($reason) && $reason !== '' ? $reason : 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);
|
$decision = static::reviewPackGenerationDecision($tenant);
|
||||||
|
|
||||||
@ -501,12 +530,12 @@ public static function reviewPackGenerationWarningReason(?Tenant $tenant = null)
|
|||||||
return is_string($reason) && $reason !== '' ? $reason : 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();
|
$tenant ??= static::panelTenantContext();
|
||||||
$user = auth()->user();
|
$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::ENVIRONMENT_REVIEW_MANAGE, $tenant)) {
|
||||||
return AuthUiTooltips::insufficientPermission();
|
return AuthUiTooltips::insufficientPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,12 +543,12 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
|
|||||||
?? static::reviewPackGenerationWarningReason($tenant);
|
?? static::reviewPackGenerationWarningReason($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function executeExport(TenantReview $review): void
|
public static function executeExport(EnvironmentReview $review): void
|
||||||
{
|
{
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||||
$user = auth()->user();
|
$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();
|
Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -587,10 +616,10 @@ public static function executeExport(TenantReview $review): void
|
|||||||
public static function tenantScopedUrl(
|
public static function tenantScopedUrl(
|
||||||
string $page = 'index',
|
string $page = 'index',
|
||||||
array $parameters = [],
|
array $parameters = [],
|
||||||
?Tenant $tenant = null,
|
?ManagedEnvironment $tenant = null,
|
||||||
?string $panel = null,
|
?string $panel = null,
|
||||||
): string {
|
): string {
|
||||||
$panelId = $panel ?? 'tenant';
|
$panelId = 'admin';
|
||||||
|
|
||||||
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
||||||
}
|
}
|
||||||
@ -600,14 +629,14 @@ public static function tenantScopedUrl(
|
|||||||
*/
|
*/
|
||||||
private static function evidenceSnapshotOptions(): array
|
private static function evidenceSnapshotOptions(): array
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return EvidenceSnapshot::query()
|
return EvidenceSnapshot::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
->whereNotNull('generated_at')
|
->whereNotNull('generated_at')
|
||||||
->orderByDesc('generated_at')
|
->orderByDesc('generated_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
@ -625,13 +654,13 @@ private static function evidenceSnapshotOptions(): array
|
|||||||
|
|
||||||
private static function reviewCompletenessCountLabel(string $state): string
|
private static function reviewCompletenessCountLabel(string $state): string
|
||||||
{
|
{
|
||||||
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
|
return BadgeCatalog::spec(BadgeDomain::EnvironmentReviewCompleteness, $state)->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function summaryPresentation(TenantReview $record): array
|
private static function summaryPresentation(EnvironmentReview $record): array
|
||||||
{
|
{
|
||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
$truthEnvelope = static::truthEnvelope($record);
|
$truthEnvelope = static::truthEnvelope($record);
|
||||||
@ -639,6 +668,10 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
||||||
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
||||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
$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) {
|
if ($findingOutcomeSummary !== null) {
|
||||||
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
||||||
@ -647,12 +680,17 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
return [
|
return [
|
||||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||||
'compressed_outcome' => static::compressedOutcome($record)->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,
|
'highlights' => $highlights,
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
'context_links' => static::summaryContextLinks($record),
|
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
|
||||||
'metrics' => [
|
'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.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||||
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||||
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||||
@ -664,13 +702,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(EnvironmentReview $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(EnvironmentReview $record): string
|
||||||
|
{
|
||||||
|
if ($record->isPublished() && (string) $record->completeness_state === EnvironmentReviewCompletenessState::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(EnvironmentReview $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(EnvironmentReview $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(EnvironmentReview $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, [
|
||||||
|
EnvironmentReviewCompletenessState::Partial->value,
|
||||||
|
EnvironmentReviewCompletenessState::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(EnvironmentReview $record, bool $customerWorkspaceMode = false): array
|
||||||
{
|
{
|
||||||
$links = [];
|
$links = [];
|
||||||
|
|
||||||
if (is_numeric($record->operation_run_id)) {
|
if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.operation'),
|
'title' => __('localization.review.operation'),
|
||||||
'label' => __('localization.review.open_operation'),
|
'label' => __('localization.review.open_operation'),
|
||||||
@ -679,7 +868,7 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->currentExportReviewPack && $record->tenant) {
|
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.executive_pack'),
|
'title' => __('localization.review.executive_pack'),
|
||||||
'label' => __('localization.review.view_executive_pack'),
|
'label' => __('localization.review.view_executive_pack'),
|
||||||
@ -698,11 +887,23 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($record->evidenceSnapshot && $record->tenant) {
|
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[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.evidence_snapshot'),
|
'title' => __('localization.review.evidence_snapshot'),
|
||||||
'label' => __('localization.review.view_evidence_snapshot'),
|
'label' => __('localization.review.view_evidence_snapshot'),
|
||||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
'url' => $evidenceUrl,
|
||||||
'description' => __('localization.review.evidence_snapshot_description'),
|
'description' => $canViewEvidence
|
||||||
|
? __('localization.review.evidence_snapshot_description')
|
||||||
|
: __('localization.review.evidence_proof_access_unavailable'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -712,12 +913,30 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function sectionPresentation(TenantReviewSection $section): array
|
private static function sectionPresentation(EnvironmentReviewSection $section): array
|
||||||
{
|
{
|
||||||
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
||||||
$review = $section->tenantReview;
|
$review = $section->environmentReview;
|
||||||
$tenant = $section->tenant;
|
$tenant = $section->tenant;
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
if ($section->isControlInterpretation() && $review instanceof EnvironmentReview && $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 [
|
return [
|
||||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
||||||
@ -730,43 +949,45 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
'value' => (string) $value,
|
'value' => (string) $value,
|
||||||
];
|
];
|
||||||
})->filter()->values()->all(),
|
})->filter()->values()->all(),
|
||||||
|
'artifact_sources' => is_array($render['artifact_sources'] ?? null) ? $render['artifact_sources'] : [],
|
||||||
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
||||||
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
||||||
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
||||||
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
||||||
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
||||||
'links' => [],
|
'is_control_interpretation' => $section->isControlInterpretation(),
|
||||||
|
'links' => $links,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
private static function truthEnvelope(EnvironmentReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
return $fresh
|
return $fresh
|
||||||
? $presenter->forTenantReviewFresh($record)
|
? $presenter->forEnvironmentReviewFresh($record)
|
||||||
: $presenter->forTenantReview($record);
|
: $presenter->forEnvironmentReview($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function truthState(TenantReview $record, bool $fresh = false): array
|
private static function truthState(EnvironmentReview $record, bool $fresh = false): array
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::tenantReview(), $fresh)
|
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::environmentReview(), $fresh)
|
||||||
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
|
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function compressedOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome
|
private static function compressedOutcome(EnvironmentReview $record, bool $fresh = false): CompressedGovernanceOutcome
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::tenantReview(), $fresh)
|
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::environmentReview(), $fresh)
|
||||||
?? $presenter->compressedOutcomeFromEnvelope(
|
?? $presenter->compressedOutcomeFromEnvelope(
|
||||||
static::truthEnvelope($record, $fresh),
|
static::truthEnvelope($record, $fresh),
|
||||||
SurfaceCompressionContext::tenantReview(),
|
SurfaceCompressionContext::environmentReview(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -783,4 +1004,34 @@ private static function findingOutcomeSummary(array $summary): ?string
|
|||||||
|
|
||||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
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(EnvironmentReview $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\EnvironmentReviewResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EnvironmentReviewResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListEnvironmentReviews extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = EnvironmentReviewResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EnvironmentReviewResource::makeCreateReviewAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,20 +2,23 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
namespace App\Filament\Resources\EnvironmentReviewResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\EnvironmentReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\TenantReview;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\EnvironmentReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
|
use App\Support\EnvironmentReviewStatus;
|
||||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
@ -23,9 +26,9 @@
|
|||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewTenantReview extends ViewRecord
|
class ViewEnvironmentReview extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantReviewResource::class;
|
protected static string $resource = EnvironmentReviewResource::class;
|
||||||
|
|
||||||
public function mount(int|string $record): void
|
public function mount(int|string $record): void
|
||||||
{
|
{
|
||||||
@ -36,20 +39,20 @@ public function mount(int|string $record): void
|
|||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
return EnvironmentReviewResource::resolveScopedRecordOrFail($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function authorizeAccess(): void
|
protected function authorizeAccess(): void
|
||||||
{
|
{
|
||||||
$tenant = TenantReviewResource::panelTenantContext();
|
$tenant = EnvironmentReviewResource::panelTenantContext();
|
||||||
$record = $this->getRecord();
|
$record = $this->getRecord();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $record instanceof EnvironmentReview) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +67,12 @@ protected function authorizeAccess(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
if ($this->isCustomerWorkspaceView()) {
|
||||||
|
return [
|
||||||
|
$this->downloadCurrentReviewPackAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$secondaryActions = $this->secondaryLifecycleActions();
|
$secondaryActions = $this->secondaryLifecycleActions();
|
||||||
|
|
||||||
return array_values(array_filter([
|
return array_values(array_filter([
|
||||||
@ -99,11 +108,11 @@ private function primaryLifecycleActionName(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
if ((string) $this->record->status === EnvironmentReviewStatus::Published->value) {
|
||||||
return 'export_executive_pack';
|
return 'export_executive_pack';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $this->record->status === TenantReviewStatus::Ready->value) {
|
if ((string) $this->record->status === EnvironmentReviewStatus::Ready->value) {
|
||||||
return 'publish_review';
|
return 'publish_review';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +157,8 @@ private function secondaryLifecycleActionNames(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (in_array((string) $this->record->status, [
|
if (in_array((string) $this->record->status, [
|
||||||
TenantReviewStatus::Ready->value,
|
EnvironmentReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
EnvironmentReviewStatus::Published->value,
|
||||||
], true)) {
|
], true)) {
|
||||||
$names[] = 'export_executive_pack';
|
$names[] = 'export_executive_pack';
|
||||||
}
|
}
|
||||||
@ -185,7 +194,7 @@ private function refreshReviewAction(): Actions\Action
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app(TenantReviewService::class)->refresh($this->record, $user);
|
app(EnvironmentReviewService::class)->refresh($this->record, $user);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
@ -195,7 +204,7 @@ private function refreshReviewAction(): Actions\Action
|
|||||||
Notification::make()->success()->title($rule->successTitle)->send();
|
Notification::make()->success()->title($rule->successTitle)->send();
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,7 +236,7 @@ private function publishReviewAction(): Actions\Action
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app(TenantReviewLifecycleService::class)->publish(
|
app(EnvironmentReviewLifecycleService::class)->publish(
|
||||||
$this->record,
|
$this->record,
|
||||||
$user,
|
$user,
|
||||||
(string) ($data['publish_reason'] ?? ''),
|
(string) ($data['publish_reason'] ?? ''),
|
||||||
@ -242,7 +251,7 @@ private function publishReviewAction(): Actions\Action
|
|||||||
Notification::make()->success()->title($rule->successTitle)->send();
|
Notification::make()->success()->title($rule->successTitle)->send();
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
@ -255,18 +264,18 @@ private function exportExecutivePackAction(): Actions\Action
|
|||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
|
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
|
||||||
TenantReviewStatus::Ready->value,
|
EnvironmentReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
EnvironmentReviewStatus::Published->value,
|
||||||
], true))
|
], true))
|
||||||
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
|
->disabled(fn (): bool => EnvironmentReviewResource::reviewPackGenerationBlocked($this->record->tenant))
|
||||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
->action(fn (): mixed => EnvironmentReviewResource::executeExport($this->record)),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->preserveDisabled()
|
->preserveDisabled()
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
$action->tooltip(fn (): ?string => EnvironmentReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
@ -286,17 +295,17 @@ private function createNextReviewAction(): Actions\Action
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
$nextReview = app(EnvironmentReviewLifecycleService::class)->createNextReview($this->record, $user);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
$this->redirect(EnvironmentReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
@ -328,7 +337,7 @@ private function archiveReviewAction(): Actions\Action
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
app(TenantReviewLifecycleService::class)->archive(
|
app(EnvironmentReviewLifecycleService::class)->archive(
|
||||||
$this->record,
|
$this->record,
|
||||||
$user,
|
$user,
|
||||||
(string) ($data['archive_reason'] ?? ''),
|
(string) ($data['archive_reason'] ?? ''),
|
||||||
@ -338,11 +347,82 @@ private function archiveReviewAction(): Actions\Action
|
|||||||
Notification::make()->success()->title($rule->successTitle)->send();
|
Notification::make()->success()->title($rule->successTitle)->send();
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->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
|
private function isCustomerWorkspaceView(): bool
|
||||||
{
|
{
|
||||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||||
@ -357,23 +437,25 @@ private function auditCustomerWorkspaceOpen(): void
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = $this->record->tenant;
|
$tenant = $this->record->tenant;
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
app(WorkspaceAuditLogger::class)->log(
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
workspace: $tenant->workspace,
|
workspace: $tenant->workspace,
|
||||||
action: AuditActionId::TenantReviewOpened,
|
action: AuditActionId::EnvironmentReviewOpened,
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'review_id' => (int) $this->record->getKey(),
|
'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,
|
actor: $user,
|
||||||
resourceType: 'tenant_review',
|
resourceType: 'environment_review',
|
||||||
resourceId: (string) $this->record->getKey(),
|
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,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -6,12 +6,13 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -21,6 +22,7 @@
|
|||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -64,6 +66,7 @@ class EvidenceSnapshotResource extends Resource
|
|||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static ?string $model = EvidenceSnapshot::class;
|
protected static ?string $model = EvidenceSnapshot::class;
|
||||||
|
|
||||||
@ -81,12 +84,18 @@ class EvidenceSnapshotResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 55;
|
protected static ?int $navigationSort = 55;
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
|
&& parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +111,7 @@ public static function canView(Model $record): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +120,7 @@ public static function canView(Model $record): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ! $record instanceof EvidenceSnapshot
|
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
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
@ -167,16 +176,19 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||||
->iconColor(BadgeRenderer::iconColor(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('generated_at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('operationRun.id')
|
TextEntry::make('operationRun.id')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab()
|
||||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
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),
|
->columns(2),
|
||||||
Section::make('Summary')
|
Section::make('Summary')
|
||||||
@ -202,6 +214,24 @@ public static function infolist(Schema $schema): Schema
|
|||||||
RepeatableEntry::make('items')
|
RepeatableEntry::make('items')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->schema([
|
->schema([
|
||||||
|
TextEntry::make('artifact_source_family')
|
||||||
|
->label('Source family')
|
||||||
|
->badge()
|
||||||
|
->state(fn (EvidenceSnapshotItem $record): string => static::artifactDescriptorValue($record, 'source_family'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_source_kind')
|
||||||
|
->label('Source kind')
|
||||||
|
->state(fn (EvidenceSnapshotItem $record): string => static::artifactDescriptorValue($record, 'source_kind'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_source_target')
|
||||||
|
->label('Source target')
|
||||||
|
->state(fn (EvidenceSnapshotItem $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_control_key')
|
||||||
|
->label('Control')
|
||||||
|
->state(fn (EvidenceSnapshotItem $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state))
|
||||||
|
->placeholder('—'),
|
||||||
TextEntry::make('dimension_key')->label('Dimension')
|
TextEntry::make('dimension_key')->label('Dimension')
|
||||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
TextEntry::make('state')
|
TextEntry::make('state')
|
||||||
@ -210,7 +240,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||||
TextEntry::make('source_kind')->label('Source')
|
TextEntry::make('source_kind')->label('Provider source detail')
|
||||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
||||||
ViewEntry::make('summary_payload_highlights')
|
ViewEntry::make('summary_payload_highlights')
|
||||||
@ -222,6 +252,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('Raw summary JSON')
|
->label('Raw summary JSON')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columns(4),
|
->columns(4),
|
||||||
@ -236,7 +267,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
{
|
{
|
||||||
$entries = [];
|
$entries = [];
|
||||||
|
|
||||||
if (is_numeric($record->operation_run_id)) {
|
if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) {
|
||||||
$entries[] = RelatedContextEntry::available(
|
$entries[] = RelatedContextEntry::available(
|
||||||
key: 'operation_run',
|
key: 'operation_run',
|
||||||
label: 'Operation',
|
label: 'Operation',
|
||||||
@ -254,13 +285,19 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
->latest('created_at')
|
->latest('created_at')
|
||||||
->first();
|
->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(
|
$entries[] = RelatedContextEntry::available(
|
||||||
key: 'review_pack',
|
key: 'review_pack',
|
||||||
label: 'Review pack',
|
label: 'Review pack',
|
||||||
value: sprintf('#%d', (int) $pack->getKey()),
|
value: sprintf('#%d', (int) $pack->getKey()),
|
||||||
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
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',
|
targetKind: 'direct_record',
|
||||||
priority: 20,
|
priority: 20,
|
||||||
actionLabel: 'View review pack',
|
actionLabel: 'View review pack',
|
||||||
@ -268,7 +305,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
)->toArray();
|
)->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
if ($record->tenant instanceof ManagedEnvironment) {
|
||||||
$entries[] = RelatedContextEntry::available(
|
$entries[] = RelatedContextEntry::available(
|
||||||
key: 'customer_review_workspace',
|
key: 'customer_review_workspace',
|
||||||
label: 'Customer workspace',
|
label: 'Customer workspace',
|
||||||
@ -285,6 +322,36 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
return $entries;
|
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
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -400,7 +467,7 @@ private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item)
|
|||||||
{
|
{
|
||||||
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
||||||
|
|
||||||
return match ($item->dimension_key) {
|
$presentation = match ($item->dimension_key) {
|
||||||
'findings_summary' => static::findingsSummaryPresentation($payload),
|
'findings_summary' => static::findingsSummaryPresentation($payload),
|
||||||
'permission_posture' => static::permissionPosturePresentation($payload),
|
'permission_posture' => static::permissionPosturePresentation($payload),
|
||||||
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
||||||
@ -408,6 +475,39 @@ private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item)
|
|||||||
'operations_summary' => static::operationsSummaryPresentation($payload),
|
'operations_summary' => static::operationsSummaryPresentation($payload),
|
||||||
default => static::genericSummaryPresentation($payload),
|
default => static::genericSummaryPresentation($payload),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$presentation['artifact_sources'] = [static::artifactSourceSummary($item)];
|
||||||
|
|
||||||
|
return $presentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function artifactDescriptorValue(EvidenceSnapshotItem $item, string $key): string
|
||||||
|
{
|
||||||
|
return (string) (static::artifactDescriptorNullableValue($item, $key) ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function artifactDescriptorNullableValue(EvidenceSnapshotItem $item, string $key): ?string
|
||||||
|
{
|
||||||
|
$descriptor = $item->artifactSourceDescriptor()->toArray();
|
||||||
|
$value = $descriptor[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{source_family: string, source_kind: string, source_target_kind: string, control_key: ?string, detector_key: ?string}
|
||||||
|
*/
|
||||||
|
private static function artifactSourceSummary(EvidenceSnapshotItem $item): array
|
||||||
|
{
|
||||||
|
$descriptor = $item->artifactSourceDescriptor();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'source_family' => $descriptor->sourceFamily,
|
||||||
|
'source_kind' => $descriptor->sourceKind,
|
||||||
|
'source_target_kind' => $descriptor->sourceTargetKind,
|
||||||
|
'control_key' => $descriptor->controlKey,
|
||||||
|
'detector_key' => $descriptor->detectorKey,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -740,10 +840,10 @@ private static function stringifySummaryValue(mixed $value): string
|
|||||||
*/
|
*/
|
||||||
public static function executeGeneration(array $data): void
|
public static function executeGeneration(array $data): void
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$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();
|
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -5,8 +5,13 @@
|
|||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
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\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||||
@ -20,6 +25,13 @@ class ViewEvidenceSnapshot extends ViewRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = EvidenceSnapshotResource::class;
|
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
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||||
@ -27,6 +39,10 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||||
|
|
||||||
@ -90,4 +106,44 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->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,13 +6,15 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\FindingExceptionEvidenceReference;
|
use App\Models\FindingExceptionEvidenceReference;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
@ -21,6 +23,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
@ -53,6 +56,7 @@ class FindingExceptionResource extends Resource
|
|||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static ?string $model = FindingException::class;
|
protected static ?string $model = FindingException::class;
|
||||||
|
|
||||||
@ -70,11 +74,8 @@ class FindingExceptionResource extends Resource
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
@ -82,7 +83,7 @@ public static function canViewAny(): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +99,7 @@ public static function canView(Model $record): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +108,7 @@ public static function canView(Model $record): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ! $record instanceof FindingException
|
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
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
@ -134,12 +135,12 @@ public static function exceptionStatsForCurrentTenant(): array
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$counts = FindingException::query()
|
$counts = FindingException::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
->where('workspace_id', (int) $tenant->workspace_id)
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
->selectRaw('count(*) as total')
|
->selectRaw('count(*) as total')
|
||||||
->selectRaw("count(*) filter (where status = 'active') as active")
|
->selectRaw("count(*) filter (where status = 'active') as active")
|
||||||
@ -191,7 +192,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
|
->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')
|
TextEntry::make('finding_summary')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
||||||
@ -264,13 +265,13 @@ public static function relatedContextEntries(FindingException $record): array
|
|||||||
{
|
{
|
||||||
$entries = [];
|
$entries = [];
|
||||||
|
|
||||||
if ($record->finding && $record->tenant instanceof Tenant) {
|
if ($record->finding && $record->tenant instanceof ManagedEnvironment) {
|
||||||
$entries[] = RelatedContextEntry::available(
|
$entries[] = RelatedContextEntry::available(
|
||||||
key: 'finding',
|
key: 'finding',
|
||||||
label: 'Finding',
|
label: 'Finding',
|
||||||
value: static::findingSummary($record),
|
value: static::findingSummary($record),
|
||||||
secondaryValue: 'Return to the linked finding detail.',
|
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',
|
targetKind: 'direct_record',
|
||||||
priority: 10,
|
priority: 10,
|
||||||
actionLabel: 'Open finding',
|
actionLabel: 'Open finding',
|
||||||
@ -278,7 +279,7 @@ public static function relatedContextEntries(FindingException $record): array
|
|||||||
)->toArray();
|
)->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
if ($record->tenant instanceof ManagedEnvironment && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
||||||
$entries[] = RelatedContextEntry::available(
|
$entries[] = RelatedContextEntry::available(
|
||||||
key: 'approval_queue',
|
key: 'approval_queue',
|
||||||
label: 'Approval queue',
|
label: 'Approval queue',
|
||||||
@ -428,7 +429,7 @@ public static function table(Table $table): Table
|
|||||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $record->tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -532,16 +533,22 @@ private static function tenantMemberOptions(): array
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return \App\Models\TenantMembership::query()
|
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
||||||
->orderBy('users.name')
|
return User::query()
|
||||||
->pluck('users.name', 'users.id')
|
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
->orderBy('name')
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
||||||
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -608,7 +615,7 @@ private static function canManageRecord(FindingException $record): bool
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
&& $record->tenant instanceof Tenant
|
&& $record->tenant instanceof ManagedEnvironment
|
||||||
&& $user->canAccessTenant($record->tenant)
|
&& $user->canAccessTenant($record->tenant)
|
||||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||||
}
|
}
|
||||||
@ -643,12 +650,12 @@ private static function governanceWarningColor(FindingException $record): string
|
|||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
|
public static function canAccessApprovalQueueForTenant(?ManagedEnvironment $tenant = null): bool
|
||||||
{
|
{
|
||||||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -665,16 +672,16 @@ public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null):
|
|||||||
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
&& $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();
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return route('admin.finding-exceptions.open-queue', [
|
return route('admin.finding-exceptions.open-queue', [
|
||||||
'tenant' => (string) $tenant->external_id,
|
'environment' => (string) $tenant->external_id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
use App\Filament\Widgets\ManagedEnvironment\FindingExceptionStatsOverview;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|||||||
@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
@ -19,6 +21,7 @@
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
@ -36,7 +39,18 @@ protected function getHeaderActions(): array
|
|||||||
$renewRule = GovernanceActionCatalog::rule('renew_exception');
|
$renewRule = GovernanceActionCatalog::rule('renew_exception');
|
||||||
$revokeRule = GovernanceActionCatalog::rule('revoke_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')
|
Action::make('renew_exception')
|
||||||
->label($renewRule->canonicalLabel)
|
->label($renewRule->canonicalLabel)
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -159,7 +173,18 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
$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,16 +200,22 @@ private function tenantMemberOptions(): array
|
|||||||
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return \App\Models\TenantMembership::query()
|
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
||||||
->orderBy('users.name')
|
return User::query()
|
||||||
->pluck('users.name', 'users.id')
|
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
->orderBy('name')
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
||||||
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,9 +225,14 @@ private function canManageRecord(): bool
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $record instanceof FindingException
|
return $record instanceof FindingException
|
||||||
&& $record->tenant instanceof Tenant
|
&& $record->tenant instanceof ManagedEnvironment
|
||||||
&& $user instanceof User
|
&& $user instanceof User
|
||||||
&& $user->canAccessTenant($record->tenant)
|
&& $user->canAccessTenant($record->tenant)
|
||||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,15 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Resources\FindingResource\Pages;
|
use App\Filament\Resources\FindingResource\Pages;
|
||||||
use App\Filament\Support\NormalizedDiffSurface;
|
use App\Filament\Support\NormalizedDiffSurface;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
@ -25,6 +26,7 @@
|
|||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -58,6 +60,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -66,6 +69,7 @@ class FindingResource extends Resource
|
|||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static ?string $model = Finding::class;
|
protected static ?string $model = Finding::class;
|
||||||
|
|
||||||
@ -77,11 +81,8 @@ class FindingResource extends Resource
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
@ -110,7 +111,7 @@ public static function canViewAny(): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +128,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +141,7 @@ public static function canView(Model $record): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($record instanceof Finding) {
|
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;
|
&& (int) $record->workspace_id === (int) $tenant->workspace_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,9 +255,50 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Artifact source')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('artifact_source_family')
|
||||||
|
->label('Source family')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_family'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_source_kind')
|
||||||
|
->label('Source kind')
|
||||||
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_kind'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_source_target')
|
||||||
|
->label('Source target')
|
||||||
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_source_target_identifier')
|
||||||
|
->label('Target identifier')
|
||||||
|
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'source_target_identifier'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('artifact_detector_key')
|
||||||
|
->label('Detector')
|
||||||
|
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'detector_key'))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('artifact_control_key')
|
||||||
|
->label('Control')
|
||||||
|
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('artifact_provider_key')
|
||||||
|
->label('Provider')
|
||||||
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'provider_key'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_provider_object_type')
|
||||||
|
->label('Provider object type')
|
||||||
|
->state(fn (Finding $record): ?string => static::artifactProviderDetailValue($record, 'provider_object_type'))
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Finding')
|
Section::make('Finding')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
TextEntry::make('finding_type')->badge()->label('Provider finding type'),
|
||||||
TextEntry::make('drift_surface_label')
|
TextEntry::make('drift_surface_label')
|
||||||
->label('Drift surface')
|
->label('Drift surface')
|
||||||
->badge()
|
->badge()
|
||||||
@ -679,17 +721,17 @@ private static function driftDiffUnavailableMessage(Finding $record): string
|
|||||||
/**
|
/**
|
||||||
* @return array{0: ?PolicyVersion, 1: ?PolicyVersion}
|
* @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');
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||||
|
|
||||||
$baselineVersion = is_numeric($baselineId)
|
$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;
|
: null;
|
||||||
|
|
||||||
$currentVersion = is_numeric($currentId)
|
$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;
|
: null;
|
||||||
|
|
||||||
return [$baselineVersion, $currentVersion];
|
return [$baselineVersion, $currentVersion];
|
||||||
@ -974,7 +1016,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -989,7 +1031,7 @@ public static function table(Table $table): Table
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -1057,7 +1099,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1076,7 +1118,7 @@ public static function table(Table $table): Table
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -1149,7 +1191,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1166,7 +1208,7 @@ public static function table(Table $table): Table
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -1228,7 +1270,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1245,7 +1287,7 @@ public static function table(Table $table): Table
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -1369,14 +1411,35 @@ private static function findingRunNavigationContext(Finding $record): CanonicalN
|
|||||||
tenantId: $tenant?->getKey(),
|
tenantId: $tenant?->getKey(),
|
||||||
backLinkLabel: 'Back to finding',
|
backLinkLabel: 'Back to finding',
|
||||||
backLinkUrl: static::getUrl('view', ['record' => $record], tenant: $tenant),
|
backLinkUrl: static::getUrl('view', ['record' => $record], tenant: $tenant),
|
||||||
filterPayload: $tenant instanceof Tenant ? [
|
filterPayload: $tenant instanceof ManagedEnvironment ? [
|
||||||
'tableFilters' => [
|
'tableFilters' => [
|
||||||
'tenant_id' => ['value' => (string) $tenant->getKey()],
|
'managed_environment_id' => ['value' => (string) $tenant->getKey()],
|
||||||
],
|
],
|
||||||
] : [],
|
] : [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function artifactDescriptorValue(Finding $record, string $key): string
|
||||||
|
{
|
||||||
|
return (string) (static::artifactDescriptorNullableValue($record, $key) ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function artifactDescriptorNullableValue(Finding $record, string $key): ?string
|
||||||
|
{
|
||||||
|
$descriptor = $record->artifactSourceDescriptor()->toArray();
|
||||||
|
$value = $descriptor[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function artifactProviderDetailValue(Finding $record, string $key): ?string
|
||||||
|
{
|
||||||
|
$detail = $record->artifactProviderDetail()->toArray();
|
||||||
|
$value = $detail[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -1418,7 +1481,7 @@ public static function triageAction(): Actions\Action
|
|||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
successTitle: 'Finding triaged',
|
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 +1505,7 @@ public static function startProgressAction(): Actions\Action
|
|||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
successTitle: 'Finding moved to in progress',
|
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 +1577,7 @@ public static function resolveAction(): Actions\Action
|
|||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
successTitle: $rule->successTitle,
|
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,
|
$finding,
|
||||||
$tenant,
|
$tenant,
|
||||||
$user,
|
$user,
|
||||||
@ -1555,7 +1618,7 @@ public static function closeAction(): Actions\Action
|
|||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
successTitle: $rule->successTitle,
|
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,
|
$finding,
|
||||||
$tenant,
|
$tenant,
|
||||||
$user,
|
$user,
|
||||||
@ -1760,7 +1823,7 @@ public static function reopenAction(): Actions\Action
|
|||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
successTitle: $rule->successTitle,
|
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,
|
$finding,
|
||||||
$tenant,
|
$tenant,
|
||||||
$user,
|
$user,
|
||||||
@ -1776,7 +1839,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
|
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||||
{
|
{
|
||||||
@ -1785,11 +1848,11 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Finding belongs to a different tenant')
|
->title('Finding belongs to a different tenant')
|
||||||
->danger()
|
->danger()
|
||||||
@ -1837,11 +1900,11 @@ private static function runResponsibilityMutation(Finding $record, array $data,
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Finding belongs to a different tenant')
|
->title('Finding belongs to a different tenant')
|
||||||
->danger()
|
->danger()
|
||||||
@ -1906,7 +1969,7 @@ private static function runExceptionRequestMutation(Finding $record, array $data
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1942,7 +2005,7 @@ private static function runExceptionRenewalMutation(Finding $record, array $data
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1978,7 +2041,7 @@ private static function runExceptionRevocationMutation(Finding $record, array $d
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2074,7 +2137,7 @@ private static function resolveCurrentFindingExceptionOrFail(Finding $record): F
|
|||||||
return $exception;
|
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();
|
$panelId = Filament::getCurrentPanel()?->getId();
|
||||||
|
|
||||||
@ -2082,7 +2145,7 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex
|
|||||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
|
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,16 +2451,22 @@ private static function tenantMemberOptions(): array
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return TenantMembership::query()
|
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
||||||
->orderBy('users.name')
|
return User::query()
|
||||||
->pluck('users.name', 'users.id')
|
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
->orderBy('name')
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
||||||
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2408,14 +2477,14 @@ public static function findingStatsForCurrentTenant(): array
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return ['open' => 0, 'overdue' => 0, 'high_severity' => 0, 'risk_accepted' => 0, 'total' => 0];
|
return ['open' => 0, 'overdue' => 0, 'high_severity' => 0, 'risk_accepted' => 0, 'total' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = now()->toDateTimeString();
|
$now = now()->toDateTimeString();
|
||||||
|
|
||||||
$counts = Finding::query()
|
$counts = Finding::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
->selectRaw('count(*) as total')
|
->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') 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])
|
->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])
|
||||||
|
|||||||
@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
use App\Filament\Widgets\ManagedEnvironment\BaselineCompareCoverageBanner;
|
||||||
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
use App\Filament\Widgets\ManagedEnvironment\FindingStatsOverview;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -152,7 +152,7 @@ protected function getHeaderActions(): array
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,9 +5,10 @@
|
|||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -17,14 +18,17 @@
|
|||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Closure;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Panel;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -33,12 +37,15 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventoryItemResource extends Resource
|
class InventoryItemResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static ?string $model = InventoryItem::class;
|
protected static ?string $model = InventoryItem::class;
|
||||||
|
|
||||||
@ -54,11 +61,40 @@ class InventoryItemResource extends Resource
|
|||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& 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
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
@ -76,7 +112,7 @@ public static function canViewAny(): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +127,7 @@ public static function canView(Model $record): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +142,7 @@ public static function canView(Model $record): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($record instanceof InventoryItem) {
|
if ($record instanceof InventoryItem) {
|
||||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
return (int) $record->managed_environment_id === (int) $tenant->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -121,11 +157,47 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Artifact source')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('artifact_source_family')
|
||||||
|
->label('Source family')
|
||||||
|
->badge()
|
||||||
|
->state(fn (InventoryItem $record): string => static::artifactDescriptorValue($record, 'source_family'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_source_kind')
|
||||||
|
->label('Source kind')
|
||||||
|
->state(fn (InventoryItem $record): string => static::artifactDescriptorValue($record, 'source_kind'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_source_target')
|
||||||
|
->label('Source target')
|
||||||
|
->state(fn (InventoryItem $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('artifact_control_key')
|
||||||
|
->label('Control')
|
||||||
|
->state(fn (InventoryItem $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state))
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Inventory Item')
|
Section::make('Inventory Item')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('display_name')->label('Name'),
|
TextEntry::make('display_name')->label('Name'),
|
||||||
|
TextEntry::make('canonical_type')
|
||||||
|
->label('Canonical type')
|
||||||
|
->badge()
|
||||||
|
->state(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'canonical_type'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('provider_display_type')
|
||||||
|
->label('Provider display type')
|
||||||
|
->state(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'provider_display_type')),
|
||||||
|
TextEntry::make('provider_object_type')
|
||||||
|
->label('Provider object type')
|
||||||
|
->state(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'provider_object_type'))
|
||||||
|
->copyable(),
|
||||||
TextEntry::make('policy_type')
|
TextEntry::make('policy_type')
|
||||||
->label('Type')
|
->label('Legacy policy type')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
@ -151,7 +223,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
|
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,11 +291,20 @@ public static function table(Table $table): Table
|
|||||||
->label('Name')
|
->label('Name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('canonical_type')
|
||||||
|
->label('Canonical type')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'canonical_type'))
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
Tables\Columns\TextColumn::make('provider_display_type')
|
||||||
|
->label('Provider display type')
|
||||||
|
->getStateUsing(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'provider_display_type')),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Legacy policy type')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('category')
|
Tables\Columns\TextColumn::make('category')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||||
@ -352,6 +433,27 @@ private static function typeMeta(?string $type): array
|
|||||||
return InventoryPolicyTypeMeta::metaFor($type);
|
return InventoryPolicyTypeMeta::metaFor($type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function typeDescriptorValue(InventoryItem $record, string $key): string
|
||||||
|
{
|
||||||
|
$descriptor = $record->inventoryTypeDescriptor();
|
||||||
|
$value = $descriptor[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function artifactDescriptorValue(InventoryItem $record, string $key): string
|
||||||
|
{
|
||||||
|
return (string) (static::artifactDescriptorNullableValue($record, $key) ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function artifactDescriptorNullableValue(InventoryItem $record, string $key): ?string
|
||||||
|
{
|
||||||
|
$descriptor = $record->artifactSourceDescriptor()->toArray();
|
||||||
|
$value = $descriptor[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use App\Jobs\RunInventorySyncJob;
|
use App\Jobs\RunInventorySyncJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
@ -28,6 +28,7 @@
|
|||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Support\Enums\Width;
|
||||||
use Filament\Support\Enums\Size;
|
use Filament\Support\Enums\Size;
|
||||||
|
|
||||||
class ListInventoryItems extends ListRecords
|
class ListInventoryItems extends ListRecords
|
||||||
@ -36,6 +37,8 @@ class ListInventoryItems extends ListRecords
|
|||||||
|
|
||||||
protected static string $resource = InventoryItemResource::class;
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
@ -46,7 +49,7 @@ public function mount(): void
|
|||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +128,7 @@ protected function getHeaderActions(): array
|
|||||||
->dehydrated()
|
->dehydrated()
|
||||||
->rules(['boolean'])
|
->rules(['boolean'])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Hidden::make('tenant_id')
|
Hidden::make('managed_environment_id')
|
||||||
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
|
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
|
||||||
->dehydrated(),
|
->dehydrated(),
|
||||||
])
|
])
|
||||||
@ -136,7 +139,7 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,11 +149,11 @@ protected function getHeaderActions(): array
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$requestedTenantId = $data['tenant_id'] ?? null;
|
$requestedTenantId = $data['managed_environment_id'] ?? null;
|
||||||
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Not allowed')
|
->title('Not allowed')
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -19,7 +19,7 @@ public function mount(int|string $record): void
|
|||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,26 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\ManagedEnvironmentResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Support\Tenants\TenantActionSurface;
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
class EditManagedEnvironment extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = ManagedEnvironmentResource::class;
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter([
|
return array_values(array_filter([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
TenantResource::makeRestoreTenantAction(
|
ManagedEnvironmentResource::makeRestoreTenantAction(
|
||||||
TenantActionSurface::TenantEditHeader,
|
TenantActionSurface::TenantEditHeader,
|
||||||
'You do not have permission to restore tenants.',
|
'You do not have permission to restore tenants.',
|
||||||
),
|
),
|
||||||
TenantResource::makeArchiveTenantAction(
|
ManagedEnvironmentResource::makeArchiveTenantAction(
|
||||||
TenantActionSurface::TenantEditHeader,
|
TenantActionSurface::TenantEditHeader,
|
||||||
'You do not have permission to archive tenants.',
|
'You do not have permission to archive tenants.',
|
||||||
),
|
),
|
||||||
@ -28,9 +28,9 @@ protected function getHeaderActions(): array
|
|||||||
->label('Lifecycle')
|
->label('Lifecycle')
|
||||||
->icon('heroicon-o-archive-box')
|
->icon('heroicon-o-archive-box')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||||
&& in_array(
|
&& in_array(
|
||||||
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
|
ManagedEnvironmentResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
|
||||||
['archive', 'restore'],
|
['archive', 'restore'],
|
||||||
true,
|
true,
|
||||||
)),
|
)),
|
||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\ManagedEnvironmentResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||||
@ -13,9 +13,9 @@
|
|||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListTenants extends ListRecords
|
class ListManagedEnvironments extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = ManagedEnvironmentResource::class;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
@ -64,7 +64,7 @@ protected function getTableEmptyStateDescription(): ?string
|
|||||||
|
|
||||||
private function makeOnboardingEntryAction(): Actions\Action
|
private function makeOnboardingEntryAction(): Actions\Action
|
||||||
{
|
{
|
||||||
$descriptor = TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($this->accessibleResumableDraftCount());
|
$descriptor = ManagedEnvironmentResource::tenantActionPolicy()->onboardingEntryDescriptor($this->accessibleResumableDraftCount());
|
||||||
|
|
||||||
return Actions\Action::make('add_tenant')
|
return Actions\Action::make('add_tenant')
|
||||||
->label($descriptor->label)
|
->label($descriptor->label)
|
||||||
@ -92,10 +92,10 @@ private function applyRequestedTriageIntent(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
|
$backupPostures = ManagedEnvironmentResource::sanitizeBackupPostures(request()->query('backup_posture'));
|
||||||
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
|
$recoveryEvidence = ManagedEnvironmentResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
|
||||||
$reviewStates = TenantResource::sanitizeReviewStates(request()->query('review_state'));
|
$reviewStates = ManagedEnvironmentResource::sanitizeReviewStates(request()->query('review_state'));
|
||||||
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
|
$triageSort = ManagedEnvironmentResource::sanitizeTriageSort(request()->query('triage_sort'));
|
||||||
|
|
||||||
foreach (['backup_posture', 'recovery_evidence', 'review_state', 'triage_sort'] as $filterName) {
|
foreach (['backup_posture', 'recovery_evidence', 'review_state', 'triage_sort'] as $filterName) {
|
||||||
data_forget($this->tableFilters, $filterName);
|
data_forget($this->tableFilters, $filterName);
|
||||||
@ -139,10 +139,10 @@ private function hasActiveTriageEmptyState(): bool
|
|||||||
public function currentPortfolioTriageReturnState(): array
|
public function currentPortfolioTriageReturnState(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'backup_posture' => TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', [])),
|
'backup_posture' => ManagedEnvironmentResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', [])),
|
||||||
'recovery_evidence' => TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', [])),
|
'recovery_evidence' => ManagedEnvironmentResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', [])),
|
||||||
'review_state' => TenantResource::sanitizeReviewStates(data_get($this->tableFilters, 'review_state.values', [])),
|
'review_state' => ManagedEnvironmentResource::sanitizeReviewStates(data_get($this->tableFilters, 'review_state.values', [])),
|
||||||
'triage_sort' => TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value')),
|
'triage_sort' => ManagedEnvironmentResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ManagedEnvironmentResource\Pages;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
|
||||||
|
class ManageEnvironmentAccessScopes extends ViewManagedEnvironment
|
||||||
|
{
|
||||||
|
protected static ?string $title = 'Manage environment access scope';
|
||||||
|
|
||||||
|
public function mount(int|string|ManagedEnvironment $environment): void
|
||||||
|
{
|
||||||
|
parent::mount($environment instanceof ManagedEnvironment ? (string) $environment->getRouteKey() : $environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
return 'Workspace membership defines the role. Explicit environment scopes only narrow which workspace members can see this environment.';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 environment overview')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => ManagedEnvironmentLinks::viewUrl($this->getRecord())),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\ManagedEnvironmentResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||||
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
use App\Filament\Widgets\ManagedEnvironment\AdminRolesSummaryWidget;
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
use App\Filament\Widgets\ManagedEnvironment\RecentOperationsSummary;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentArchivedBanner;
|
||||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentVerificationReport;
|
||||||
use App\Jobs\RefreshTenantRbacHealthJob;
|
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -22,9 +22,9 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
class ViewTenant extends ViewRecord
|
class ViewManagedEnvironment extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = ManagedEnvironmentResource::class;
|
||||||
|
|
||||||
public static function verificationHeaderActionLabel(): string
|
public static function verificationHeaderActionLabel(): string
|
||||||
{
|
{
|
||||||
@ -44,9 +44,9 @@ public function getHeaderWidgetsColumns(): int|array
|
|||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TenantArchivedBanner::class,
|
ManagedEnvironmentArchivedBanner::class,
|
||||||
RecentOperationsSummary::class,
|
RecentOperationsSummary::class,
|
||||||
TenantVerificationReport::class,
|
ManagedEnvironmentVerificationReport::class,
|
||||||
AdminRolesSummaryWidget::class,
|
AdminRolesSummaryWidget::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -54,27 +54,28 @@ protected function getHeaderWidgets(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter([
|
return array_values(array_filter([
|
||||||
|
ManagedEnvironmentResource::makeMembershipsAction(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
TenantResource::makeAdminConsentAction(),
|
ManagedEnvironmentResource::makeAdminConsentAction(),
|
||||||
TenantResource::makeOpenInEntraAction(),
|
ManagedEnvironmentResource::makeOpenInEntraAction(),
|
||||||
])
|
])
|
||||||
->label('External links')
|
->label('External links')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||||
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
|
&& ManagedEnvironmentResource::tenantViewExternalGroupVisible($this->getRecord())),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
TenantResource::makeSyncTenantAction(),
|
ManagedEnvironmentResource::makeSyncTenantAction(),
|
||||||
TenantResource::makeVerifyConfigurationAction('tenant_view_header'),
|
ManagedEnvironmentResource::makeVerifyConfigurationAction('tenant_view_header'),
|
||||||
TenantResource::rbacAction(),
|
ManagedEnvironmentResource::rbacAction(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('refresh_rbac')
|
Actions\Action::make('refresh_rbac')
|
||||||
->label('Refresh RBAC status')
|
->label('Refresh RBAC status')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (ManagedEnvironment $record): bool => ManagedEnvironmentResource::tenantSetupMutationVisible($record))
|
||||||
->action(function (Tenant $record): void {
|
->action(function (ManagedEnvironment $record): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
@ -92,7 +93,7 @@ protected function getHeaderActions(): array
|
|||||||
tenant: $record,
|
tenant: $record,
|
||||||
type: OperationRunType::RbacHealthCheck->value,
|
type: OperationRunType::RbacHealthCheck->value,
|
||||||
inputs: [
|
inputs: [
|
||||||
'tenant_id' => (int) $record->getKey(),
|
'managed_environment_id' => (int) $record->getKey(),
|
||||||
'surface' => 'tenant_view_header',
|
'surface' => 'tenant_view_header',
|
||||||
],
|
],
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
@ -138,26 +139,28 @@ protected function getHeaderActions(): array
|
|||||||
->label('Setup')
|
->label('Setup')
|
||||||
->icon('heroicon-o-wrench-screwdriver')
|
->icon('heroicon-o-wrench-screwdriver')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||||
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
|
&& ManagedEnvironmentResource::tenantViewSetupGroupVisible($this->getRecord())),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
TenantResource::makeTenantViewMarkReviewedAction(),
|
ManagedEnvironmentResource::makeTenantViewMarkReviewedAction(),
|
||||||
TenantResource::makeTenantViewMarkFollowUpNeededAction(),
|
ManagedEnvironmentResource::makeTenantViewMarkFollowUpNeededAction(),
|
||||||
])
|
])
|
||||||
->label('Triage')
|
->label('Triage')
|
||||||
->icon('heroicon-o-check-circle')
|
->icon('heroicon-o-check-circle')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||||
&& TenantResource::tenantViewTriageGroupVisible($this->getRecord())),
|
&& ManagedEnvironmentResource::tenantViewTriageGroupVisible($this->getRecord())),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
|
ManagedEnvironmentResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
|
||||||
TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
|
ManagedEnvironmentResource::makeRestoreTenantToWorkspaceAction(),
|
||||||
|
ManagedEnvironmentResource::makeRemoveTenantFromWorkspaceAction(),
|
||||||
|
ManagedEnvironmentResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
|
||||||
])
|
])
|
||||||
->label('Lifecycle')
|
->label('Lifecycle')
|
||||||
->icon('heroicon-o-archive-box')
|
->icon('heroicon-o-archive-box')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
->visible(fn (): bool => $this->getRecord() instanceof ManagedEnvironment
|
||||||
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
|
&& ManagedEnvironmentResource::tenantViewLifecycleGroupVisible($this->getRecord())),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
namespace App\Filament\Resources\ManagedEnvironmentResource\RelationManagers;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ManageEnvironmentAccessScopes;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\ManagedEnvironmentMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\ManagedEnvironmentMembershipManager;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -22,27 +22,27 @@
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class TenantMembershipsRelationManager extends RelationManager
|
class ManagedEnvironmentMembershipsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
protected static string $relationship = 'memberships';
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Add explicit access scope 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 access scope 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::ListRowMoreMenu, 'Remove stays direct for focused inline scope management.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk access scope mutations are intentionally omitted.')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add explicit access scope remains available in the header.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
|
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
|
||||||
{
|
{
|
||||||
if (! $ownerRecord instanceof Tenant) {
|
if (! $ownerRecord instanceof ManagedEnvironment) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pageClass !== ManageTenantMemberships::class) {
|
if ($pageClass !== ManageEnvironmentAccessScopes::class) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +74,7 @@ public function table(Table $table): Table
|
|||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('user_domain')
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
->label(__('Domain'))
|
->label(__('Domain'))
|
||||||
->getStateUsing(function (TenantMembership $record): ?string {
|
->getStateUsing(function (ManagedEnvironmentMembership $record): ?string {
|
||||||
$email = $record->user?->email;
|
$email = $record->user?->email;
|
||||||
|
|
||||||
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||||
@ -86,9 +86,6 @@ public function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('user.name')
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
->label(__('Name'))
|
->label(__('Name'))
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('role')
|
|
||||||
->badge()
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('source')
|
Tables\Columns\TextColumn::make('source')
|
||||||
->badge()
|
->badge()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
@ -97,34 +94,19 @@ public function table(Table $table): Table
|
|||||||
->headerActions([
|
->headerActions([
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Action::make('add_member')
|
Action::make('add_member')
|
||||||
->label(__('Add member'))
|
->label(__('Add explicit access scope'))
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Select::make('user_id')
|
Forms\Components\Select::make('user_id')
|
||||||
->label(__('User'))
|
->label(__('Workspace member'))
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->searchable()
|
||||||
->options(fn () => User::query()
|
->options(fn (): array => $this->workspaceMemberOptions()),
|
||||||
->orderBy('email')
|
|
||||||
->get(['id', 'name', 'email'])
|
|
||||||
->mapWithKeys(fn (User $user): array => [
|
|
||||||
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
|
||||||
])
|
|
||||||
->all()),
|
|
||||||
Forms\Components\Select::make('role')
|
|
||||||
->label(__('Role'))
|
|
||||||
->required()
|
|
||||||
->options([
|
|
||||||
'owner' => __('Owner'),
|
|
||||||
'manager' => __('Manager'),
|
|
||||||
'operator' => __('Operator'),
|
|
||||||
'readonly' => __('Readonly'),
|
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
->action(function (array $data, ManagedEnvironmentMembershipManager $manager): void {
|
||||||
$tenant = $this->getOwnerRecord();
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,16 +123,15 @@ public function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$manager->addMember(
|
$manager->grantScope(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
member: $member,
|
member: $member,
|
||||||
role: (string) $data['role'],
|
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
);
|
);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('Failed to add member'))
|
->title(__('Failed to add explicit access scope'))
|
||||||
->body($throwable->getMessage())
|
->body($throwable->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@ -158,80 +139,26 @@ public function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()->title(__('Member added'))->success()->send();
|
Notification::make()->title(__('Explicit access scope added'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
fn () => $this->getOwnerRecord(),
|
fn () => $this->getOwnerRecord(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
->tooltip('You do not have permission to manage tenant memberships.')
|
->tooltip('You do not have permission to manage environment access scopes.')
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('change_role')
|
|
||||||
->label(__('Change role'))
|
|
||||||
->icon('heroicon-o-pencil')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Forms\Components\Select::make('role')
|
|
||||||
->label(__('Role'))
|
|
||||||
->required()
|
|
||||||
->options([
|
|
||||||
'owner' => __('Owner'),
|
|
||||||
'manager' => __('Manager'),
|
|
||||||
'operator' => __('Operator'),
|
|
||||||
'readonly' => __('Readonly'),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$actor = auth()->user();
|
|
||||||
if (! $actor instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$manager->changeRole(
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $actor,
|
|
||||||
membership: $record,
|
|
||||||
newRole: (string) $data['role'],
|
|
||||||
);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('Failed to change role'))
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()->title(__('Role updated'))->success()->send();
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
fn () => $this->getOwnerRecord(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
|
||||||
->tooltip('You do not have permission to manage tenant memberships.')
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Action::make('remove')
|
Action::make('remove')
|
||||||
->label(__('Remove'))
|
->label(__('Remove explicit scope'))
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
->action(function (ManagedEnvironmentMembership $record, ManagedEnvironmentMembershipManager $manager): void {
|
||||||
$tenant = $this->getOwnerRecord();
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,7 +171,7 @@ public function table(Table $table): Table
|
|||||||
$manager->removeMember($tenant, $actor, $record);
|
$manager->removeMember($tenant, $actor, $record);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('Failed to remove member'))
|
->title(__('Failed to remove explicit access scope'))
|
||||||
->body($throwable->getMessage())
|
->body($throwable->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@ -252,18 +179,40 @@ public function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()->title(__('Member removed'))->success()->send();
|
Notification::make()->title(__('Explicit access scope removed'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
fn () => $this->getOwnerRecord(),
|
fn () => $this->getOwnerRecord(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
->tooltip('You do not have permission to manage tenant memberships.')
|
->tooltip('You do not have permission to manage environment access scopes.')
|
||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('No tenant members'))
|
->emptyStateHeading(__('No explicit access scopes'))
|
||||||
->emptyStateDescription(__('Add a member to delegate access inside this tenant.'));
|
->emptyStateDescription(__('Workspace members inherit access unless explicit scopes narrow that member to selected environments.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function workspaceMemberOptions(): array
|
||||||
|
{
|
||||||
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if (! $tenant instanceof ManagedEnvironment || ! is_numeric($tenant->workspace_id)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()
|
||||||
|
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||||
|
->whereDoesntHave('tenantMemberships', fn (Builder $query): Builder => $query->where('managed_environment_id', (int) $tenant->getKey()))
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
use App\Filament\Support\VerificationReportViewer;
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\VerificationCheckAcknowledgement;
|
use App\Models\VerificationCheckAcknowledgement;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
@ -175,8 +175,8 @@ public static function table(Table $table): Table
|
|||||||
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
|
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
Tables\Filters\SelectFilter::make('managed_environment_id')
|
||||||
->label('Tenant')
|
->label('ManagedEnvironment')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -185,7 +185,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
||||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -193,7 +193,7 @@ public static function table(Table $table): Table
|
|||||||
->default(function (): ?string {
|
->default(function (): ?string {
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,7 +278,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||||
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this operation.';
|
$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 : []);
|
$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)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
: null;
|
: null;
|
||||||
$artifactTruth = static::artifactTruthEnvelope($record);
|
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||||
@ -520,7 +520,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
entries: [
|
entries: [
|
||||||
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
||||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
$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.',
|
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
@ -620,7 +620,7 @@ private static function supportingGroups(
|
|||||||
$lifecycleItems = array_values(array_filter([
|
$lifecycleItems = array_values(array_filter([
|
||||||
$referencedTenantLifecycle !== null
|
$referencedTenantLifecycle !== null
|
||||||
? $factory->keyFact(
|
? $factory->keyFact(
|
||||||
'Tenant lifecycle',
|
'ManagedEnvironment lifecycle',
|
||||||
$referencedTenantLifecycle->presentation->label,
|
$referencedTenantLifecycle->presentation->label,
|
||||||
badge: $factory->statusBadge(
|
badge: $factory->statusBadge(
|
||||||
$referencedTenantLifecycle->presentation->label,
|
$referencedTenantLifecycle->presentation->label,
|
||||||
@ -631,7 +631,7 @@ private static function supportingGroups(
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
||||||
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
? $factory->keyFact('ManagedEnvironment selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
||||||
: null,
|
: null,
|
||||||
$referencedTenantLifecycle?->contextNote !== null
|
$referencedTenantLifecycle?->contextNote !== null
|
||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
@ -1266,13 +1266,13 @@ private static function verificationReportViewData(OperationRun $record): array
|
|||||||
if ($changeIndicator !== null) {
|
if ($changeIndicator !== null) {
|
||||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
$previousRunUrl = $tenant instanceof Tenant
|
$previousRunUrl = $tenant instanceof ManagedEnvironment
|
||||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
$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('workspace_id', (int) ($record->workspace_id ?? 0))
|
||||||
->where('operation_run_id', (int) $record->getKey())
|
->where('operation_run_id', (int) $record->getKey())
|
||||||
->with('acknowledgedByUser')
|
->with('acknowledgedByUser')
|
||||||
@ -1605,7 +1605,7 @@ public static function restoreContinuation(OperationRun $record): ?array
|
|||||||
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
|
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$canOpenRestore = $tenant instanceof Tenant
|
$canOpenRestore = $tenant instanceof ManagedEnvironment
|
||||||
&& $user instanceof User
|
&& $user instanceof User
|
||||||
&& app(\App\Services\Auth\CapabilityResolver::class)->isMember($user, $tenant);
|
&& 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,
|
'follow_up_required' => $attention->followUpRequired,
|
||||||
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
|
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
|
||||||
'link_url' => $canOpenRestore
|
'link_url' => $canOpenRestore
|
||||||
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant)
|
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant)
|
||||||
: null,
|
: null,
|
||||||
'link_available' => $canOpenRestore,
|
'link_available' => $canOpenRestore,
|
||||||
];
|
];
|
||||||
@ -1657,6 +1657,21 @@ private static function targetScopeDisplay(OperationRun $record): ?string
|
|||||||
return null;
|
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;
|
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
|
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
|
||||||
use App\Filament\Resources\PolicyResource\Pages;
|
use App\Filament\Resources\PolicyResource\Pages;
|
||||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
use App\Filament\Support\NormalizedSettingsSurface;
|
use App\Filament\Support\NormalizedSettingsSurface;
|
||||||
@ -13,7 +14,7 @@
|
|||||||
use App\Jobs\BulkPolicyUnignoreJob;
|
use App\Jobs\BulkPolicyUnignoreJob;
|
||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
@ -25,6 +26,7 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\NavigationScope;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -61,6 +63,7 @@ class PolicyResource extends Resource
|
|||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
use ScopesGlobalSearchToTenant;
|
use ScopesGlobalSearchToTenant;
|
||||||
|
use WorkspaceScopedTenantRoutes;
|
||||||
|
|
||||||
protected static ?string $model = Policy::class;
|
protected static ?string $model = Policy::class;
|
||||||
|
|
||||||
@ -72,13 +75,20 @@ class PolicyResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
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
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||||||
return false;
|
&& parent::shouldRegisterNavigation();
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
@ -86,7 +96,7 @@ public static function canViewAny(): bool
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +110,7 @@ public static function canViewAny(): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
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::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
->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.')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||||
@ -112,17 +122,17 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
|||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make($name)
|
Actions\Action::make($name)
|
||||||
->label('Sync from Intune')
|
->label(static::text('resource.sync_action_primary'))
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Sync policies from Intune')
|
->modalHeading(static::text('resource.sync_modal_heading'))
|
||||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
->modalDescription(static::text('resource.sync_modal_description').' '.static::text('common.source_microsoft_intune'))
|
||||||
->action(function (Pages\ListPolicies $livewire): void {
|
->action(function (Pages\ListPolicies $livewire): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +160,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
|||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -165,14 +175,14 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->tooltip('You do not have permission to sync policies.')
|
->tooltip(static::text('resource.sync_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,16 +195,31 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Policy Details')
|
Section::make(static::text('resource.details_section'))
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('display_name')->label('Policy'),
|
TextEntry::make('display_name')->label(static::text('common.policy')),
|
||||||
TextEntry::make('policy_type')->label('Type'),
|
TextEntry::make('policy_type')->label(static::text('common.type')),
|
||||||
TextEntry::make('platform'),
|
TextEntry::make('platform')
|
||||||
TextEntry::make('external_id')->label('External ID'),
|
->label(static::text('common.platform'))
|
||||||
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
->badge()
|
||||||
TextEntry::make('created_at')->since(),
|
->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')
|
TextEntry::make('latest_snapshot_mode')
|
||||||
->label('Snapshot')
|
->label(static::text('common.snapshot'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||||
@ -211,8 +236,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$status = $meta['original_status'] ?? null;
|
$status = $meta['original_status'] ?? null;
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
static::text('resource.snapshot_metadata_only_helper'),
|
||||||
$status ?? 'an error'
|
$status ?? static::text('resource.graph_error_fallback')
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||||
@ -225,7 +250,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->activeTab(1)
|
->activeTab(1)
|
||||||
->persistTabInQueryString()
|
->persistTabInQueryString()
|
||||||
->tabs([
|
->tabs([
|
||||||
Tab::make('General')
|
Tab::make(static::text('resource.tab_general'))
|
||||||
->id('general')
|
->id('general')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('policy_general')
|
ViewEntry::make('policy_general')
|
||||||
@ -236,7 +261,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||||
Tab::make('Settings')
|
Tab::make(static::text('common.settings'))
|
||||||
->id('settings')
|
->id('settings')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('settings')
|
ViewEntry::make('settings')
|
||||||
@ -248,12 +273,12 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||||
|
|
||||||
TextEntry::make('no_settings_available')
|
TextEntry::make('no_settings_available')
|
||||||
->label('Settings')
|
->label(static::text('common.settings'))
|
||||||
->state('No policy snapshot available yet.')
|
->state(static::text('resource.settings_empty_state'))
|
||||||
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
|
->helperText(static::text('resource.settings_empty_state_helper'))
|
||||||
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
||||||
]),
|
]),
|
||||||
Tab::make('JSON')
|
Tab::make(static::text('resource.tab_json'))
|
||||||
->id('json')
|
->id('json')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('snapshot_json')
|
ViewEntry::make('snapshot_json')
|
||||||
@ -261,7 +286,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->state(fn (Policy $record) => static::latestSnapshot($record))
|
->state(fn (Policy $record) => static::latestSnapshot($record))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
TextEntry::make('snapshot_size')
|
TextEntry::make('snapshot_size')
|
||||||
->label('Payload Size')
|
->label(static::text('resource.payload_size'))
|
||||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||||
->formatStateUsing(function ($state) {
|
->formatStateUsing(function ($state) {
|
||||||
if ($state > 512000) {
|
if ($state > 512000) {
|
||||||
@ -269,7 +294,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<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"/>
|
<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>
|
</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>';
|
</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,7 +309,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
||||||
|
|
||||||
// Legacy layout (kept for fallback if tabs are disabled)
|
// Legacy layout (kept for fallback if tabs are disabled)
|
||||||
Section::make('Settings')
|
Section::make(static::text('common.settings'))
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('settings')
|
ViewEntry::make('settings')
|
||||||
->label('')
|
->label('')
|
||||||
@ -298,7 +323,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return ! static::usesTabbedLayout($record);
|
return ! static::usesTabbedLayout($record);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Section::make('Policy Snapshot (JSON)')
|
Section::make(static::text('resource.snapshot_json_section'))
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('snapshot_json')
|
ViewEntry::make('snapshot_json')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
@ -306,7 +331,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
TextEntry::make('snapshot_size')
|
TextEntry::make('snapshot_size')
|
||||||
->label('Payload Size')
|
->label(static::text('resource.payload_size'))
|
||||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||||
->formatStateUsing(function ($state) {
|
->formatStateUsing(function ($state) {
|
||||||
if ($state > 512000) {
|
if ($state > 512000) {
|
||||||
@ -314,7 +339,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<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"/>
|
<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>
|
</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>';
|
</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,11 +361,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $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')
|
->defaultSort('display_name')
|
||||||
->paginated(TablePaginationProfiles::resource())
|
->paginated(TablePaginationProfiles::resource())
|
||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
@ -349,24 +369,36 @@ public static function table(Table $table): Table
|
|||||||
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Policy')
|
->label(static::text('common.policy'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label(static::text('common.type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||||
->iconColor(TagBadgeRenderer::iconColor(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')
|
Tables\Columns\TextColumn::make('category')
|
||||||
->label('Category')
|
->label(static::text('common.category'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('restore_mode')
|
Tables\Columns\TextColumn::make('restore_mode')
|
||||||
->label('Restore')
|
->label(static::text('common.restore'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||||
@ -374,19 +406,22 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||||
Tables\Columns\TextColumn::make('platform')
|
Tables\Columns\TextColumn::make('platform')
|
||||||
|
->label(static::text('common.platform'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('settings_status')
|
Tables\Columns\TextColumn::make('settings_status')
|
||||||
->label('Settings')
|
->label(static::text('common.settings'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(function (Policy $record) {
|
->state(function (Policy $record) {
|
||||||
$latest = $record->versions->first();
|
$latest = $record->versions->first();
|
||||||
$snapshot = $latest?->snapshot ?? [];
|
$snapshot = $latest?->snapshot ?? [];
|
||||||
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
$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) {
|
->color(function (Policy $record) {
|
||||||
$latest = $record->versions->first();
|
$latest = $record->versions->first();
|
||||||
@ -396,12 +431,12 @@ public static function table(Table $table): Table
|
|||||||
return $hasSettings ? 'success' : 'gray';
|
return $hasSettings ? 'success' : 'gray';
|
||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('external_id')
|
Tables\Columns\TextColumn::make('external_id')
|
||||||
->label('External ID')
|
->label(static::text('common.external_id'))
|
||||||
->copyable()
|
->copyable()
|
||||||
->limit(32)
|
->limit(32)
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('last_synced_at')
|
Tables\Columns\TextColumn::make('last_synced_at')
|
||||||
->label('Last synced')
|
->label(static::text('common.last_synced'))
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
@ -411,27 +446,35 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('visibility')
|
Tables\Filters\SelectFilter::make('visibility')
|
||||||
->label('Visibility')
|
->label(static::text('common.visibility'))
|
||||||
->options([
|
->options([
|
||||||
'active' => 'Active',
|
'active' => static::text('resource.filter_active'),
|
||||||
'ignored' => 'Ignored',
|
'ignored' => static::text('resource.filter_ignored'),
|
||||||
|
'provider_missing' => static::text('resource.filter_source_unavailable'),
|
||||||
|
'all' => static::text('resource.filter_all'),
|
||||||
])
|
])
|
||||||
->default('active')
|
->default('active')
|
||||||
->query(function (Builder $query, array $data) {
|
->query(function (Builder $query, array $data) {
|
||||||
$value = $data['value'] ?? null;
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
if (blank($value)) {
|
if (blank($value) || $value === 'all') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($value === 'active') {
|
if ($value === 'active') {
|
||||||
$query->whereNull('ignored_at');
|
$query->active();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($value === 'ignored') {
|
if ($value === 'ignored') {
|
||||||
$query->whereNotNull('ignored_at');
|
$query->whereNotNull('ignored_at');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 'provider_missing') {
|
||||||
|
$query->whereNotNull('missing_from_provider_at');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Tables\Filters\SelectFilter::make('policy_type')
|
Tables\Filters\SelectFilter::make('policy_type')
|
||||||
@ -475,20 +518,22 @@ public static function table(Table $table): Table
|
|||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export')
|
Actions\Action::make('export')
|
||||||
->label('Export to Backup')
|
->label(static::text('resource.export_to_backup'))
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->disabled(fn (Policy $record): bool => ! $record->isCurrentBackupEligible())
|
||||||
|
->tooltip(fn (Policy $record): ?string => $record->currentBackupBlockedReasonLabel())
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label(static::text('common.backup_name'))
|
||||||
->required()
|
->required()
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Policy $record, array $data): void {
|
->action(function (Policy $record, array $data): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,6 +541,16 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $record->isCurrentBackupEligible()) {
|
||||||
|
Notification::make()
|
||||||
|
->title(static::text('resource.current_backup_unavailable'))
|
||||||
|
->body($record->currentBackupBlockedReasonLabel())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$ids = [(int) $record->getKey()];
|
$ids = [(int) $record->getKey()];
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -510,7 +565,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'policy.export',
|
type: 'policy.export',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
|
||||||
@ -533,7 +588,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -541,11 +596,12 @@ public static function table(Table $table): Table
|
|||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveDisabled()
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('sync')
|
Actions\Action::make('sync')
|
||||||
->label('Sync')
|
->label(static::text('resource.sync_action_secondary'))
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -554,7 +610,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,7 +635,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -592,7 +648,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -604,7 +660,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label(static::text('resource.restore_action'))
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -613,19 +669,19 @@ public static function table(Table $table): Table
|
|||||||
$record->unignore();
|
$record->unignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy restored')
|
->title(static::text('resource.policy_restored'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to restore policies.')
|
->tooltip(static::text('resource.restore_permission_tooltip'))
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('ignore')
|
Actions\Action::make('ignore')
|
||||||
->label('Ignore')
|
->label(static::text('resource.ignore_action'))
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -634,31 +690,31 @@ public static function table(Table $table): Table
|
|||||||
$record->ignore();
|
$record->ignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy ignored')
|
->title(static::text('resource.policy_ignored'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to ignore policies.')
|
->tooltip(static::text('resource.ignore_permission_tooltip'))
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('More')
|
->label(static::text('common.more'))
|
||||||
->icon('heroicon-o-ellipsis-vertical'),
|
->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_export')
|
BulkAction::make('bulk_export')
|
||||||
->label('Export to Backup')
|
->label(static::text('resource.export_to_backup'))
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label(static::text('common.backup_name'))
|
||||||
->required()
|
->required()
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data): void {
|
->action(function (Collection $records, array $data): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -666,7 +722,7 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -674,6 +730,20 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
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 */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
@ -686,7 +756,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'policy.export',
|
type: 'policy.export',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
||||||
@ -721,7 +791,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -732,7 +802,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_sync')
|
BulkAction::make('bulk_sync')
|
||||||
->label('Sync Policies')
|
->label(static::text('resource.sync_action_primary'))
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -746,7 +816,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -779,7 +849,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -792,7 +862,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -803,7 +873,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_restore')
|
BulkAction::make('bulk_restore')
|
||||||
->label('Restore Policies')
|
->label(static::text('resource.restore_bulk_action'))
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -819,7 +889,7 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof ManagedEnvironment) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -839,7 +909,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'policy.unignore',
|
type: 'policy.unignore',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
||||||
@ -873,7 +943,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -884,7 +954,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_delete')
|
BulkAction::make('bulk_delete')
|
||||||
->label('Ignore Policies')
|
->label(static::text('resource.ignore_bulk_action'))
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -898,11 +968,11 @@ public static function table(Table $table): Table
|
|||||||
if ($records->count() >= 20) {
|
if ($records->count() >= 20) {
|
||||||
return [
|
return [
|
||||||
Forms\Components\TextInput::make('confirmation')
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->label('Type DELETE to confirm')
|
->label(static::text('common.type_delete_to_confirm'))
|
||||||
->required()
|
->required()
|
||||||
->in(['DELETE'])
|
->in(['DELETE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type DELETE to confirm.',
|
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -915,7 +985,7 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -931,7 +1001,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'policy.delete',
|
type: 'policy.delete',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
|
||||||
@ -955,10 +1025,10 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body("Queued deletion for {$count} policies.")
|
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -967,10 +1037,10 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->body("Queued deletion for {$count} policies.")
|
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -979,10 +1049,10 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
])->label('More'),
|
])->label(static::text('common.more')),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No policies synced yet')
|
->emptyStateHeading(static::text('resource.empty_state_heading'))
|
||||||
->emptyStateDescription('Sync your first tenant to see Intune policies here.')
|
->emptyStateDescription(static::text('resource.empty_state_description'))
|
||||||
->emptyStateIcon('heroicon-o-arrow-path')
|
->emptyStateIcon('heroicon-o-arrow-path')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
static::makeSyncAction(),
|
static::makeSyncAction(),
|
||||||
@ -1159,25 +1229,25 @@ private static function generalOverviewState(Policy $record): array
|
|||||||
|
|
||||||
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
||||||
if (is_string($name) && $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;
|
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
||||||
if (is_string($platforms) && $platforms !== '') {
|
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 !== []) {
|
} elseif (is_array($platforms) && $platforms !== []) {
|
||||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||||
}
|
}
|
||||||
|
|
||||||
$technologies = $snapshot['technologies'] ?? null;
|
$technologies = $snapshot['technologies'] ?? null;
|
||||||
if (is_string($technologies) && $technologies !== '') {
|
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 !== []) {
|
} 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)) {
|
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']
|
$settingCount = $snapshot['settingCount']
|
||||||
@ -1185,29 +1255,29 @@ private static function generalOverviewState(Policy $record): array
|
|||||||
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
||||||
|
|
||||||
if (is_int($settingCount) || is_numeric($settingCount)) {
|
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;
|
$version = $snapshot['version'] ?? null;
|
||||||
if (is_string($version) && $version !== '') {
|
if (is_string($version) && $version !== '') {
|
||||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||||
} elseif (is_numeric($version)) {
|
} elseif (is_numeric($version)) {
|
||||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||||
}
|
}
|
||||||
|
|
||||||
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
||||||
if (is_string($lastModified) && $lastModified !== '') {
|
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;
|
$createdAt = $snapshot['createdDateTime'] ?? null;
|
||||||
if (is_string($createdAt) && $createdAt !== '') {
|
if (is_string($createdAt) && $createdAt !== '') {
|
||||||
$entries[] = ['key' => 'Created', 'value' => $createdAt];
|
$entries[] = ['key' => static::text('resource.general_field_created'), 'value' => $createdAt];
|
||||||
}
|
}
|
||||||
|
|
||||||
$description = $snapshot['description'] ?? null;
|
$description = $snapshot['description'] ?? null;
|
||||||
if (is_string($description) && $description !== '') {
|
if (is_string($description) && $description !== '') {
|
||||||
$entries[] = ['key' => 'Description', 'value' => $description];
|
$entries[] = ['key' => static::text('resource.general_field_description'), 'value' => $description];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -1232,4 +1302,9 @@ private static function settingsTabState(Policy $record): array
|
|||||||
|
|
||||||
return $normalized;
|
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\Filament\Resources\PolicyResource;
|
||||||
use App\Jobs\CapturePolicySnapshotJob;
|
use App\Jobs\CapturePolicySnapshotJob;
|
||||||
|
use App\Models\Policy;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
@ -39,23 +40,37 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
{
|
{
|
||||||
$action = UiEnforcement::forAction(
|
$action = UiEnforcement::forAction(
|
||||||
Action::make('capture_snapshot')
|
Action::make('capture_snapshot')
|
||||||
->label('Capture snapshot')
|
->label($this->text('resource.capture_snapshot_action'))
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Capture snapshot now')
|
->modalHeading($this->text('resource.capture_snapshot_modal_heading'))
|
||||||
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
|
->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([
|
->form([
|
||||||
Forms\Components\Checkbox::make('include_assignments')
|
Forms\Components\Checkbox::make('include_assignments')
|
||||||
->label('Include assignments')
|
->label($this->text('resource.capture_snapshot_include_assignments'))
|
||||||
->default(true)
|
->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')
|
Forms\Components\Checkbox::make('include_scope_tags')
|
||||||
->label('Include scope tags')
|
->label($this->text('resource.capture_snapshot_include_scope_tags'))
|
||||||
->default(true)
|
->default(true)
|
||||||
->helperText('Captures policy scope tag IDs.'),
|
->helperText($this->text('resource.capture_snapshot_include_scope_tags_helper')),
|
||||||
])
|
])
|
||||||
->action(function (array $data, AuditLogger $auditLogger) {
|
->action(function (array $data, AuditLogger $auditLogger) {
|
||||||
$policy = $this->record;
|
$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;
|
$tenant = $policy->tenant;
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -82,7 +97,7 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'policy.capture_snapshot',
|
type: 'policy.capture_snapshot',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $includeAssignments, $includeScopeTags): void {
|
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $includeAssignments, $includeScopeTags): void {
|
||||||
@ -108,11 +123,11 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated) {
|
if (! $opRun->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Snapshot already in progress')
|
->title($this->text('resource.capture_snapshot_in_progress_title'))
|
||||||
->body('An active run already exists for this policy. Opening run details.')
|
->body($this->text('resource.capture_snapshot_in_progress_body'))
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label($this->text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->info()
|
->info()
|
||||||
@ -145,7 +160,7 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label($this->text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -155,7 +170,8 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
->color('primary')
|
->color('primary')
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->tooltip('You do not have permission to capture policy snapshots.')
|
->tooltip($this->text('resource.capture_snapshot_permission_tooltip'))
|
||||||
|
->preserveDisabled()
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
if (! $action instanceof Action) {
|
if (! $action instanceof Action) {
|
||||||
@ -164,4 +180,9 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function text(string $key, array $replace = []): string
|
||||||
|
{
|
||||||
|
return __('localization.policy.'.$key, $replace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user