Compare commits
125 Commits
096-ops-po
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fdca43fd | |||
| 0d5d1fc9f4 | |||
| d8e331e92f | |||
| 3c38192405 | |||
| e02799b383 | |||
| c0f4587d90 | |||
| 4699f13a72 | |||
| bb72a54e84 | |||
| ad16eee591 | |||
| d644265d30 | |||
| 7541b1eb41 | |||
| a2a42d4e5f | |||
| 1c291fb9fe | |||
| acc8947384 | |||
| efd4f31ba3 | |||
| 68be99e27b | |||
| bef9020159 | |||
| 9f6985291e | |||
| 74210bac2e | |||
| f7bbea2623 | |||
| 65e10a2020 | |||
| eca19819d1 | |||
| 2f45ff5a84 | |||
| 1655cc481e | |||
| 28e62bd22c | |||
| 9fbd3e5ec7 | |||
| 53e799fea7 | |||
| f1a73490e4 | |||
| 03b1beb616 | |||
| ce0615a9c1 | |||
| 6f8eb28ca2 | |||
| e840007127 | |||
| a107e7e41b | |||
| 1142d283eb | |||
| f52d52540c | |||
| dc46c4fa58 | |||
| 98be510362 | |||
| 44898a98ac | |||
| 3a2a06e8d7 | |||
| 671abbed53 | |||
| 1b88d28739 | |||
| fdd3a85b64 | |||
| 37c6d0622c | |||
| 807d574d31 | |||
| d98dc30520 | |||
| 55aef627aa | |||
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 | |||
| 92f39d9749 | |||
| 3c3daae405 | |||
| a4f2629493 | |||
| b1e1e06861 | |||
| a74ab12f04 | |||
| 5ec62cd117 | |||
| ec71c2d4e7 | |||
| 1f3619bd16 | |||
| 5bcb4f6ab8 | |||
| ede4cc363d | |||
| 417df4f9aa | |||
| 73a879d061 | |||
| 6ca496233b | |||
| 440e63edff | |||
| b0a724acef | |||
| 641bb4afde | |||
| 3f6f80f7af | |||
| 0b5cadc234 | |||
| d2f2c55ead | |||
| b182f55562 | |||
| 98e2b5acd9 | |||
| bab01f07a9 | |||
| 45a804970e | |||
| cc93329672 | |||
| 28cfe38ba4 | |||
| d4fb886de0 | |||
| 8ee1174c8d | |||
| b15d1950b4 | |||
| 4b3113498c | |||
| 3c445709af | |||
| 0c709df54e | |||
| ef41c9193a | |||
| c6e7591d19 | |||
| a490261eca | |||
| 02028be7e4 | |||
| a4f5c4f122 | |||
| 3971c315d8 | |||
| bfc483e9b8 | |||
| 4db73e872e | |||
| 73a3a62451 | |||
| 891f177311 | |||
| cd811cff4f | |||
| da1adbdeb5 | |||
| 92704a2f7e | |||
| f08924525d | |||
| 7620144ab6 | |||
| fdfb781144 | |||
| 0cf612826f | |||
| 200498fa8e | |||
| 32c3a64147 | |||
| 7ac53f4cc4 | |||
| f13a4ce409 | |||
| 9f5c99317b | |||
| 0dc79520a4 | |||
| e15eee8f26 | |||
| 8bee824966 | |||
| 33a2b1a242 | |||
| 6a15fe978a | |||
| ef380b67d1 | |||
| d32b2115a8 | |||
| 558b5d3807 | |||
| 8f8bc24d1d | |||
| a30be84084 | |||
| d49d33ac27 | |||
| 3ed275cef3 | |||
| c57f680f39 | |||
| e241e27853 | |||
| 521fb6baaf | |||
| ef5c223172 | |||
| 9d0c884251 | |||
| 03127a670b |
167
.agents/skills/pest-testing/SKILL.md
Normal file
167
.agents/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
|
||||||
129
.agents/skills/tailwindcss-development/SKILL.md
Normal file
129
.agents/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
.codex/config.toml
Normal file
4
.codex/config.toml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[mcp_servers.laravel-boost]
|
||||||
|
command = "vendor/bin/sail"
|
||||||
|
args = ["artisan", "boost:mcp"]
|
||||||
|
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"
|
||||||
76
.codex/prompts/tenantpilot.audit.md
Normal file
76
.codex/prompts/tenantpilot.audit.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
@ -1,5 +1,12 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
apps/platform/node_modules/
|
||||||
|
apps/website/node_modules/
|
||||||
|
apps/website/.astro/
|
||||||
|
apps/website/dist/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
coverage/
|
coverage/
|
||||||
.git/
|
.git/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@ -16,12 +23,19 @@ Dockerfile*
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
apps/platform/public/hot/
|
||||||
public/storage/
|
public/storage/
|
||||||
|
apps/platform/public/storage/
|
||||||
storage/framework/
|
storage/framework/
|
||||||
|
apps/platform/storage/framework/
|
||||||
storage/logs/
|
storage/logs/
|
||||||
|
apps/platform/storage/logs/
|
||||||
storage/debugbar/
|
storage/debugbar/
|
||||||
|
apps/platform/storage/debugbar/
|
||||||
storage/*.key
|
storage/*.key
|
||||||
|
apps/platform/storage/*.key
|
||||||
/references/
|
/references/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
194
.github/agents/copilot-instructions.md
vendored
194
.github/agents/copilot-instructions.md
vendored
@ -2,6 +2,14 @@ # TenantAtlas Development Guidelines
|
|||||||
|
|
||||||
Auto-generated from all feature plans. Last updated: 2025-12-22
|
Auto-generated from all feature plans. Last updated: 2025-12-22
|
||||||
|
|
||||||
|
## Relocation override
|
||||||
|
- The authoritative Laravel application root is `apps/platform`.
|
||||||
|
- Human-facing commands should use `cd apps/platform && ...`.
|
||||||
|
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
|
||||||
|
- Repo-root JavaScript orchestration uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||||
@ -29,27 +37,201 @@ ## Active Technologies
|
|||||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
||||||
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
|
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
|
||||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
|
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
|
||||||
|
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
|
||||||
|
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class (103-ia-scope-filter-semantics)
|
||||||
|
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
|
||||||
|
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
|
||||||
|
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
|
||||||
|
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
|
||||||
|
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
|
||||||
|
- PHP 8.4 + Laravel 12, Filament v5, Livewire v4 (116-baseline-drift-engine)
|
||||||
|
- PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail (120-secret-redaction-integrity)
|
||||||
|
- PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables) (120-secret-redaction-integrity)
|
||||||
|
- PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4 (121-workspace-switch-fix)
|
||||||
|
- PostgreSQL + session-backed workspace context; no schema changes (121-workspace-switch-fix)
|
||||||
|
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4 (122-empty-state-consistency)
|
||||||
|
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
|
||||||
|
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
|
||||||
|
- PostgreSQL primary app database (123-operations-auto-refresh)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog` (124-inventory-coverage-table)
|
||||||
|
- N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged (124-inventory-coverage-table)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components (125-table-ux-standardization)
|
||||||
|
- PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only (125-table-ux-standardization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables (126-filter-ux-standardization)
|
||||||
|
- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup)
|
||||||
|
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
|
||||||
|
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
|
||||||
|
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
|
||||||
|
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
|
||||||
|
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||||
|
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
|
||||||
|
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
|
||||||
|
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
|
||||||
|
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
|
||||||
|
- PostgreSQL application database (135-canonical-tenant-context-resolution)
|
||||||
|
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
|
||||||
|
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
|
||||||
|
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||||
|
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||||
|
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
|
||||||
|
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
|
||||||
|
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
|
||||||
|
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
|
||||||
|
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
|
||||||
|
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||||
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
|
||||||
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
|
||||||
|
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
||||||
|
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
||||||
|
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
|
||||||
|
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
||||||
|
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||||
|
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||||
|
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||||
|
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
||||||
|
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
||||||
|
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
||||||
|
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
||||||
|
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
||||||
|
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
|
||||||
|
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
||||||
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
|
||||||
|
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
|
||||||
|
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167 (168-tenant-governance-aggregate-contract)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, or durable summary artifac (168-tenant-governance-aggregate-contract)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs (169-action-surface-v11)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, queue payload, or durable artifac (169-action-surface-v11)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire v4, Filament v5 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (170-system-operations-surface-alignment)
|
||||||
|
- PostgreSQL with existing `operation_runs` and `audit_logs` tables; no schema changes (170-system-operations-surface-alignment)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (171-operations-naming-consolidation)
|
||||||
|
- PostgreSQL with existing `operation_runs`, notification payloads, workspace records, and tenant records; no schema changes (171-operations-naming-consolidation)
|
||||||
|
- PostgreSQL with existing `operation_runs`, `managed_tenant_onboarding_sessions`, tenant records, and workspace records; no schema changes (172-deferred-operator-surfaces-retrofit)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page (173-tenant-dashboard-truth-alignment)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifac (173-tenant-dashboard-truth-alignment)
|
||||||
|
- PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages (174-evidence-freshness-publication-trust)
|
||||||
|
- PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials (179-provider-truth-cleanup)
|
||||||
|
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack (177-inventory-coverage-truth)
|
||||||
|
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages (178-ops-truth-alignment)
|
||||||
|
- PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers (181-restore-safety-integrity)
|
||||||
|
- PostgreSQL with existing `restore_runs` and `operation_runs` records plus JSON or array-backed `metadata`, `preview`, `results`, and `context`; no schema change planned (181-restore-safety-integrity)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers (176-backup-quality-truth)
|
||||||
|
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned (176-backup-quality-truth)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers (180-tenant-backup-health)
|
||||||
|
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned (180-tenant-backup-health)
|
||||||
|
- PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose (182-platform-relocation)
|
||||||
|
- PostgreSQL, Redis, filesystem storage under the Laravel app `storage/` tree, plus existing Vite build artifacts in `public/build`; no new database persistence planned (182-platform-relocation)
|
||||||
|
- PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose (183-website-workspace-foundation)
|
||||||
|
- Existing PostgreSQL, Redis, and filesystem storage for `apps/platform`; static build artifacts for `apps/website`; repository-managed workspace manifests and docs; no new database persistence (183-website-workspace-foundation)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers (184-dashboard-recovery-honesty)
|
||||||
|
- PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned (184-dashboard-recovery-honesty)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces (185-workspace-recovery-posture-visibility)
|
||||||
|
- PostgreSQL unchanged; no schema change, new cache table, or persisted workspace recovery artifact is planned (185-workspace-recovery-posture-visibility)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure (186-tenant-registry-recovery-triage)
|
||||||
|
- PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages (187-portfolio-triage-arrival-context)
|
||||||
|
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup)
|
||||||
|
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
|
||||||
|
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
|
||||||
|
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
||||||
|
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders (192-record-header-discipline)
|
||||||
|
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy)
|
||||||
|
- PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`) (194-governance-friction-hardening)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers (195-action-surface-closure)
|
||||||
|
- PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned (195-action-surface-closure)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers (196-hard-filament-nativity-cleanup)
|
||||||
|
- PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned (196-hard-filament-nativity-cleanup)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService` (202-governance-subject-taxonomy)
|
||||||
|
- PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services (203-baseline-compare-strategy)
|
||||||
|
- PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces (204-platform-core-vocabulary-hardening)
|
||||||
|
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services (205-compare-job-cleanup)
|
||||||
|
- PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned (205-compare-job-cleanup)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable` (197-shared-detail-contract)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages (198-monitoring-page-state)
|
||||||
|
- PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned (198-monitoring-page-state)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Pest Browser plugin, Filament v5, Livewire v4, Laravel Sail (206-test-suite-governance)
|
||||||
|
- SQLite `:memory:` for the default test configuration, dedicated PostgreSQL config for the schema-level `Pgsql` suite, and local runner artifacts under `apps/platform/storage/logs/test-lanes` (206-test-suite-governance)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail (207-shared-test-fixture-slimming)
|
||||||
|
- SQLite `:memory:` for the default test environment, isolated PostgreSQL coverage via the existing dedicated suite, and lane-measurement artifacts under the app-root contract path `storage/logs/test-lanes` (207-shared-test-fixture-slimming)
|
||||||
|
- SQLite `:memory:` for the default test environment, existing lane artifacts under the app-root contract path `storage/logs/test-lanes`, and no new product persistence (208-heavy-suite-segmentation)
|
||||||
|
- SQLite `:memory:` for the default test environment, mixed database strategy for some heavy-governance families as declared in `TestLaneManifest`, and existing lane artifacts under the app-root contract path `storage/logs/test-lanes` (209-heavy-governance-cost)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
apps/
|
||||||
tests/
|
platform/
|
||||||
|
website/
|
||||||
|
docs/
|
||||||
|
specs/
|
||||||
|
scripts/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
# Add commands for PHP 8.4.15
|
- Root workspace:
|
||||||
|
- `corepack pnpm install`
|
||||||
|
- `corepack pnpm dev:platform`
|
||||||
|
- `corepack pnpm dev:website`
|
||||||
|
- `corepack pnpm dev`
|
||||||
|
- `corepack pnpm build:website`
|
||||||
|
- `corepack pnpm build:platform`
|
||||||
|
- Platform app:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
|
- 209-heavy-governance-cost: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
|
||||||
- 090-action-surface-contract-compliance: Added PHP 8.4.15
|
- 208-heavy-suite-segmentation: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
|
||||||
- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4
|
- 207-shared-test-fixture-slimming: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
53
.github/copilot-instructions.md
vendored
53
.github/copilot-instructions.md
vendored
@ -40,7 +40,7 @@ ## 3) Panel setup defaults
|
|||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
@ -254,7 +254,7 @@ ## Testing
|
|||||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
@ -291,8 +291,12 @@ ## Application Structure & Architecture
|
|||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Workspace Commands
|
||||||
|
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Replies
|
## Replies
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
@ -372,28 +376,29 @@ ## Enums
|
|||||||
## Laravel Sail
|
## Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- The canonical application working directory is `apps/platform`. Repo-root launchers such as MCP or VS Code tasks may use `./scripts/platform-sail`, but that helper is compatibility-only.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
## Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
@ -404,7 +409,7 @@ ### Database
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
@ -428,10 +433,10 @@ ### Configuration
|
|||||||
### Testing
|
### Testing
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
### Vite Error
|
### Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
@ -460,7 +465,7 @@ ### Models
|
|||||||
## Livewire
|
## Livewire
|
||||||
|
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
- Use the `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||||
- State should live on the server, with the UI reflecting it.
|
- State should live on the server, with the UI reflecting it.
|
||||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||||
|
|
||||||
@ -504,8 +509,8 @@ ## Testing Livewire
|
|||||||
|
|
||||||
## Laravel Pint Code Formatter
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test`, simply run `cd apps/platform && ./vendor/bin/sail bin pint` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
@ -514,7 +519,7 @@ ### Testing
|
|||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
### Pest Tests
|
### Pest Tests
|
||||||
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
- All tests must be written using Pest. Use `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
@ -527,9 +532,9 @@ ### Pest Tests
|
|||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
- To run all tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`.
|
||||||
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
- To filter on a particular test name: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||||
|
|
||||||
### Pest Assertions
|
### Pest Assertions
|
||||||
|
|||||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
agent: speckit.specify
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
8
.github/skills/giteaflow/SKILL.md
vendored
Normal file
8
.github/skills/giteaflow/SKILL.md
vendored
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 dev with gitea mcp
|
||||||
167
.github/skills/pest-testing/SKILL.md
vendored
Normal file
167
.github/skills/pest-testing/SKILL.md
vendored
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
|
||||||
129
.github/skills/tailwindcss-development/SKILL.md
vendored
Normal file
129
.github/skills/tailwindcss-development/SKILL.md
vendored
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
|
||||||
24
.gitignore
vendored
24
.gitignore
vendored
@ -15,22 +15,46 @@
|
|||||||
/.zed
|
/.zed
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/apps/platform/node_modules
|
||||||
|
/apps/website/node_modules
|
||||||
|
/.pnpm-store
|
||||||
|
/apps/website/.astro
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
coverage/
|
coverage/
|
||||||
/public/build
|
/public/build
|
||||||
|
/apps/platform/public/build
|
||||||
|
/apps/website/dist
|
||||||
/public/hot
|
/public/hot
|
||||||
|
/apps/platform/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/apps/platform/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
|
/apps/platform/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/apps/platform/storage/pail
|
||||||
/storage/framework
|
/storage/framework
|
||||||
|
/apps/platform/storage/framework
|
||||||
/storage/logs
|
/storage/logs
|
||||||
|
/apps/platform/storage/logs
|
||||||
|
/apps/platform/storage/logs/*
|
||||||
|
!/apps/platform/storage/logs/test-lanes/
|
||||||
|
/apps/platform/storage/logs/test-lanes/*
|
||||||
|
!/apps/platform/storage/logs/test-lanes/.gitignore
|
||||||
/storage/debugbar
|
/storage/debugbar
|
||||||
|
/apps/platform/storage/debugbar
|
||||||
/vendor
|
/vendor
|
||||||
|
/apps/platform/vendor
|
||||||
/bootstrap/cache
|
/bootstrap/cache
|
||||||
|
/apps/platform/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/references
|
/references
|
||||||
|
/tests/Browser/Screenshots
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
|
/apps/platform/.env
|
||||||
|
/apps/platform/.env.*
|
||||||
|
/apps/website/.env
|
||||||
|
/apps/website/.env.*
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
apps/platform/node_modules/
|
||||||
|
apps/website/node_modules/
|
||||||
|
apps/website/.astro/
|
||||||
|
apps/website/dist/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@ -2,12 +2,22 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
apps/platform/public/hot/
|
||||||
public/storage/
|
public/storage/
|
||||||
|
apps/platform/public/storage/
|
||||||
coverage/
|
coverage/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
|
apps/platform/node_modules/
|
||||||
|
apps/website/node_modules/
|
||||||
|
apps/website/.astro/
|
||||||
|
apps/website/dist/
|
||||||
storage/
|
storage/
|
||||||
|
apps/platform/storage/
|
||||||
bootstrap/cache/
|
bootstrap/cache/
|
||||||
|
apps/platform/bootstrap/cache/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
236
.specify/memory/spec-approval-rubric.md
Normal file
236
.specify/memory/spec-approval-rubric.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# TenantPilot Spec Approval Rubric (Anti-Overengineering Guardrails)
|
||||||
|
|
||||||
|
## Leitsatz
|
||||||
|
|
||||||
|
> Kein neuer Layer ohne klaren Operatorgewinn, und kein neuer Spec nur für interne semantische Schönheit.
|
||||||
|
|
||||||
|
Ein neuer Spec ist nur dann stark genug, wenn er **sichtbar mehr Produktwahrheit oder Operator-Wirkung** erzeugt als er dauerhafte Systemkomplexität importiert.
|
||||||
|
|
||||||
|
Jeder Spec muss zwei Dinge gleichzeitig beweisen:
|
||||||
|
|
||||||
|
1. Welches echte Problem wird gelöst?
|
||||||
|
2. Warum ist diese Lösung die kleinste enterprise-taugliche Form?
|
||||||
|
|
||||||
|
Wenn der Spec nur interne Eleganz, feinere Semantik oder mehr Konsistenz bringt, aber keinen klaren Workflow-, Trust- oder Audit-Gewinn, dann ist er **verdächtig**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 Pflichtfragen vor jeder Freigabe
|
||||||
|
|
||||||
|
Ein Spec darf nur weiterverfolgt werden, wenn diese 5 Fragen sauber beantwortet sind.
|
||||||
|
|
||||||
|
### A. Welcher konkrete Operator-Workflow wird besser?
|
||||||
|
|
||||||
|
Nicht abstrakt „Konsistenz verbessern", sondern konkret: welcher Nutzer, auf welcher Fläche, in welchem Schritt, mit welchem heutigen Schmerz, und was danach schneller, sicherer oder ehrlicher wird.
|
||||||
|
|
||||||
|
Wenn kein klarer Vorher/Nachher-Workflow benennbar ist → Spec ist zu abstrakt.
|
||||||
|
|
||||||
|
### B. Welche falsche oder gefährliche Produktaussage wird verhindert?
|
||||||
|
|
||||||
|
Legitime Antworten:
|
||||||
|
|
||||||
|
- Falscher „alles okay"-Eindruck
|
||||||
|
- Irreführende Recovery-Claims
|
||||||
|
- Unsaubere Ownership
|
||||||
|
- Fehlende nächste Aktion
|
||||||
|
- Fehlende Audit-Nachvollziehbarkeit
|
||||||
|
- Tenant/Workspace Leakage
|
||||||
|
- RBAC-Missverständnisse
|
||||||
|
|
||||||
|
Wenn ein Spec weder Workflow noch Trust verbessert → kaum zu rechtfertigen.
|
||||||
|
|
||||||
|
### C. Was ist die kleinste brauchbare Version?
|
||||||
|
|
||||||
|
Explizit benennen:
|
||||||
|
|
||||||
|
- Was ist die v1-Minimalversion?
|
||||||
|
- Welche Teile sind bewusst nicht enthalten?
|
||||||
|
- Welche Generalisierung wird absichtlich verschoben?
|
||||||
|
|
||||||
|
Wenn v1 wie ein Framework, eine Plattform oder eine universelle Taxonomie klingt → zu groß.
|
||||||
|
|
||||||
|
### D. Welche dauerhafte Komplexität entsteht?
|
||||||
|
|
||||||
|
Nicht nur Implementierungsaufwand, sondern Dauerfolgen:
|
||||||
|
|
||||||
|
- Neue Models / Tables?
|
||||||
|
- Neue Enums / Statusachsen?
|
||||||
|
- Neue UI-Semantik?
|
||||||
|
- Neue cross-surface Contracts?
|
||||||
|
- Neue Tests, die dauerhaft gepflegt werden müssen?
|
||||||
|
- Neue Begriffe, die jeder verstehen muss?
|
||||||
|
|
||||||
|
Wenn die Liste lang ist → Produktgewinn muss entsprechend hoch sein.
|
||||||
|
|
||||||
|
### E. Warum jetzt?
|
||||||
|
|
||||||
|
Legitime Gründe:
|
||||||
|
|
||||||
|
- Blockiert Kernworkflow
|
||||||
|
- Verhindert gefährliche Fehlinterpretation
|
||||||
|
- Ist Voraussetzung für unmittelbar folgende Hauptdomäne
|
||||||
|
- Beseitigt echten systemischen Widerspruch
|
||||||
|
- Wird bereits von mehreren Flächen schmerzhaft benötigt
|
||||||
|
|
||||||
|
Schwache Gründe:
|
||||||
|
|
||||||
|
- „wäre sauberer"
|
||||||
|
- „brauchen wir später bestimmt"
|
||||||
|
- „passt gut zur Architektur"
|
||||||
|
- „macht das Modell vollständiger"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 Spec-Klassen
|
||||||
|
|
||||||
|
Jeden Kandidaten zwingend in genau eine Klasse einordnen.
|
||||||
|
|
||||||
|
### Klasse 1 — Core Enterprise Spec
|
||||||
|
|
||||||
|
Mindestens eins muss stimmen:
|
||||||
|
|
||||||
|
- Schützt echte System-/Tenant-/RBAC-Korrektheit
|
||||||
|
- Verhindert falsche Governance-/Recovery-/Audit-Aussagen
|
||||||
|
- Schließt klaren Workflow-Gap
|
||||||
|
- Beseitigt cross-surface Widerspruch mit realem Operator-Schaden
|
||||||
|
- Ist echte Voraussetzung für eine wichtige Produktfunktion
|
||||||
|
|
||||||
|
Dürfen Komplexität einführen, aber nur gezielt.
|
||||||
|
|
||||||
|
### Klasse 2 — Workflow Compression Spec
|
||||||
|
|
||||||
|
Gut, wenn sie:
|
||||||
|
|
||||||
|
- Klickpfade verkürzen
|
||||||
|
- Kontextverlust senken
|
||||||
|
- Return-/Drilldown-Kontinuität verbessern
|
||||||
|
- Triage-/Review-/Run-Bearbeitung beschleunigen
|
||||||
|
|
||||||
|
Nützlich, aber klein halten.
|
||||||
|
|
||||||
|
### Klasse 3 — Cleanup / Consolidation
|
||||||
|
|
||||||
|
- Vereinfachung, Zusammenführung, Entkopplung
|
||||||
|
- Entfernen von Legacy / Duplikaten
|
||||||
|
- Reduktion unnötiger Schichten
|
||||||
|
|
||||||
|
Explizit erwünscht als Gegengewicht zu Wachstum.
|
||||||
|
|
||||||
|
### Klasse 4 — Premature / Defer
|
||||||
|
|
||||||
|
Wenn der Kandidat hauptsächlich bringt:
|
||||||
|
|
||||||
|
- Neue Semantik, Frameworks, Taxonomien
|
||||||
|
- Generalisierung für künftige Fälle
|
||||||
|
- Infrastruktur ohne breite aktuelle Nutzung
|
||||||
|
|
||||||
|
→ Nicht freigeben. Verschieben oder brutal einkürzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rote Flaggen
|
||||||
|
|
||||||
|
Wenn **zwei oder mehr** zutreffen → Spec muss aktiv verteidigt werden.
|
||||||
|
|
||||||
|
| # | Rote Flagge | Prüffrage |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Neue Achsen** — neues Truth-Modell, Statusdimension, Taxonomie, Bewertungsachse | Braucht der Operator das wirklich, oder nur das Modell? |
|
||||||
|
| 2 | **Neue Meta-Infrastruktur** — Presenter, Resolver, Catalog, Matrix, Registry, Builder, Policy-Layer | Sehr hoher Beweiswert nötig. |
|
||||||
|
| 3 | **Viele Flächen, wenig Nutzerwert** — 6 Flächen „harmonisiert", kein klarer Nutzerflow besser | Architektur um ihrer selbst willen? |
|
||||||
|
| 4 | **Klingt nach Foundation** — foundation, framework, generalized, reusable, future-proof, canonical semantics | Fast immer erklärungsbedürftig. |
|
||||||
|
| 5 | **Mehr Begriffe als Outcomes** — lange semantische Erklärung, Nutzerverbesserung kaum in einem Satz | Verdächtig. |
|
||||||
|
| 6 | **Mehrere Mikrospecs für eine Domäne** — foundation + semantics + presentation + hardening + integration | Zu fein zerlegt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grüne Flaggen
|
||||||
|
|
||||||
|
- Löst klar beobachtbaren Operator-Schmerz
|
||||||
|
- Verbessert echte Entscheidungssituation
|
||||||
|
- Verhindert konkrete Fehlinterpretation
|
||||||
|
- Reduziert Navigation oder Denkaufwand
|
||||||
|
- Vereinfacht bereits existierende Komplexität
|
||||||
|
- Führt wenig neue Begriffe ein
|
||||||
|
- Hat klare Nicht-Ziele
|
||||||
|
- Ist in einer Sitzung gut erklärbar
|
||||||
|
- Braucht keine neue Meta-Schicht
|
||||||
|
- Macht mehrere Flächen einfacher statt abstrakter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewertungsraster (0–2 pro Dimension)
|
||||||
|
|
||||||
|
| Dimension | 0 | 1 | 2 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nutzen** | unklar | lokal nützlich | klarer Workflow-/Trust-/Audit-Gewinn |
|
||||||
|
| **Dringlichkeit** | kann warten | sinnvoll bald | blockiert oder schützt Wichtiges jetzt |
|
||||||
|
| **Scope-Disziplin** | wirkt wie Framework/Plattform | etwas breit | klar begrenzte v1 |
|
||||||
|
| **Komplexitätslast** | hohe dauerhafte Last | mittel | niedrig / gut beherrschbar |
|
||||||
|
| **Produktnähe** | vor allem intern/architektonisch | gemischt | direkt spürbar für Operatoren |
|
||||||
|
| **Wiederverwendung belegt** | hypothetisch | wahrscheinlich | bereits an mehreren echten Stellen nötig |
|
||||||
|
|
||||||
|
### Auswertung
|
||||||
|
|
||||||
|
| Score | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| **10–12** | Freigabefähig |
|
||||||
|
| **7–9** | Nur freigeben wenn Scope enger gezogen wird |
|
||||||
|
| **4–6** | Verschieben oder zu Cleanup/Micro-Follow-up downgraden |
|
||||||
|
| **0–3** | Nicht freigeben |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TenantPilot-spezifische Regeln
|
||||||
|
|
||||||
|
### Regel A — Keine neue semantische Achse ohne UI-Beweis
|
||||||
|
|
||||||
|
Wo wird sie sichtbar? Warum reichen bestehende Achsen nicht? Welche Fehlentscheidung bleibt ohne sie bestehen?
|
||||||
|
|
||||||
|
### Regel B — Keine neue Support-/Presentation-Schicht ohne ≥ 3 echte Verbraucher
|
||||||
|
|
||||||
|
Registry, Resolver, Catalog, Presenter, Matrix, Explanation-Layer → nur mit mindestens drei echten (nicht künstlich erzeugten) Verbrauchern. Sonst lokal lösen.
|
||||||
|
|
||||||
|
### Regel C — Keine Spec-Aufspaltung unterhalb Operator-Domäne
|
||||||
|
|
||||||
|
Wenn ein Thema nicht eigenständig als Operator-Problem beschrieben werden kann → kein eigener Spec.
|
||||||
|
|
||||||
|
### Regel D — Jeder neue Status braucht eine echte Folgehandlung
|
||||||
|
|
||||||
|
Neue Status/Outcome nur erlaubt wenn sie etwas Konkretes ändern: andere nächste Aktion, anderes Routing, andere Audit-Bedeutung, andere Workflow-Behandlung.
|
||||||
|
|
||||||
|
### Regel E — Consolidation ist ein legitimer Spec-Typ
|
||||||
|
|
||||||
|
Zusammenführen von Semantik, Reduktion von Komplexität, Entfernen von Parallelmodellen, Vereinfachung von Navigation/Resolvern, Rückbau unnötiger Zwischenlayer — aktiv Platz geben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabe-Template (Pflichtabschnitt in spec.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Spec Candidate Check
|
||||||
|
|
||||||
|
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
|
||||||
|
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
|
||||||
|
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
|
||||||
|
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
|
||||||
|
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
|
||||||
|
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
|
||||||
|
- **Why now**: [Warum jetzt wichtiger als später?]
|
||||||
|
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
|
||||||
|
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
|
||||||
|
- **Red flags triggered**: [Welche roten Flaggen treffen zu?]
|
||||||
|
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
|
||||||
|
- **Decision**: [approve / shrink / merge / defer / reject]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erlaubt vs. Verdächtig (Schnellreferenz)
|
||||||
|
|
||||||
|
| Erlaubt | Verdächtig |
|
||||||
|
|---|---|
|
||||||
|
| Echte Workflow-Specs | Neue truth sub-axes |
|
||||||
|
| Governance-/Finding-/Review-Bearbeitbarkeit | Neue explanation frameworks |
|
||||||
|
| Trust-/Audit-/RBAC-Härtung | Neue presentation taxonomies |
|
||||||
|
| Portfolio-Operator-Durchsatzverbesserungen | Neue generalized support layers |
|
||||||
|
| Consolidation-Specs | Mikro-Specs für bereits stark zerlegte Domänen |
|
||||||
@ -41,11 +41,52 @@ ## Constitution Check
|
|||||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||||
|
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
|
||||||
|
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
|
||||||
|
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
|
||||||
|
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
|
||||||
|
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
|
||||||
|
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
|
||||||
|
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||||
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
|
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||||
|
- Decision-first operating model (DECIDE-001): each changed
|
||||||
|
operator-facing surface is classified as Primary Decision,
|
||||||
|
Secondary Context, or Tertiary Evidence / Diagnostics; primary
|
||||||
|
surfaces justify the human-in-the-loop moment, default-visible info
|
||||||
|
is limited to first-decision needs, deep proof is progressive
|
||||||
|
disclosed, one governance case stays decidable in one context where
|
||||||
|
practical, navigation follows workflows not storage structures, and
|
||||||
|
automation / alerts reduce attention load instead of adding noise
|
||||||
|
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||||
|
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||||
|
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||||
|
- UI/UX placeholder ban (UI-HARD-001): empty `ActionGroup` / `BulkActionGroup` placeholders and declaration-only UI conformance are forbidden
|
||||||
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
|
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||||
|
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||||
|
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
||||||
|
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||||
|
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||||
|
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||||
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||||
|
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||||
|
- Action-surface discipline (ACTSURF-001 / HDR-001): every changed
|
||||||
|
surface declares one broad action-surface class; the spec names the
|
||||||
|
one likely next operator action; navigation is separated from
|
||||||
|
mutation; record/detail/edit pages keep at most one visible primary
|
||||||
|
header action; monitoring/workbench surfaces separate scope/context,
|
||||||
|
selection actions, navigation, and object actions; risky or rare
|
||||||
|
actions are grouped and ordered by meaning/frequency/risk; any special
|
||||||
|
type or workflow-hub exception is explicit and justified
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
### Documentation (this feature)
|
### Documentation (this feature)
|
||||||
@ -109,9 +150,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|-----------|------------|-------------------------------------|
|
|-----------|------------|-------------------------------------|
|
||||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
|
||||||
|
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk requires this?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current code cannot serve safely or clearly]
|
||||||
|
- **Narrowest correct implementation**: [Why this shape is the smallest viable one]
|
||||||
|
- **Ownership cost created**: [Maintenance, testing, cognitive load, migration, or review burden]
|
||||||
|
- **Alternative intentionally rejected**: [Simpler option and why it failed]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|||||||
@ -5,6 +5,24 @@ # Feature Specification: [FEATURE NAME]
|
|||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Input**: User description: "$ARGUMENTS"
|
**Input**: User description: "$ARGUMENTS"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
<!-- This section MUST be completed before the spec progresses beyond Draft.
|
||||||
|
See .specify/memory/spec-approval-rubric.md for the full rubric. -->
|
||||||
|
|
||||||
|
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
|
||||||
|
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
|
||||||
|
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
|
||||||
|
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
|
||||||
|
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
|
||||||
|
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
|
||||||
|
- **Why now**: [Warum jetzt wichtiger als später?]
|
||||||
|
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
|
||||||
|
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
|
||||||
|
- **Red flags triggered**: [Welche roten Flaggen treffen zu? Wenn ≥ 2: explizite Verteidigung nötig]
|
||||||
|
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
|
||||||
|
- **Decision**: [approve / shrink / merge / defer / reject]
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
- **Scope**: [workspace | tenant | canonical-view]
|
- **Scope**: [workspace | tenant | canonical-view]
|
||||||
@ -17,6 +35,59 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes an operator-facing surface,
|
||||||
|
fill out one row per affected surface. This role is orthogonal to the
|
||||||
|
Action Surface Class / Surface Type below.
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
|
fill out one row per affected surface. Declare the broad Action Surface
|
||||||
|
Class first, then the detailed Surface Type. Keep this table in sync
|
||||||
|
with the Decision-First Surface Role section above.
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | List / Table / Bulk | CRUD / List-first Resource | Open policy for review | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds a new operator-facing page or materially refactors
|
||||||
|
one, fill out one row per affected page/surface. The contract MUST show
|
||||||
|
how one governance case or operator task becomes decidable without
|
||||||
|
unnecessary cross-page reconstruction.
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | Tenant operator | Decide whether policy state needs follow-up | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
Fill this section if the feature introduces any of the following:
|
||||||
|
- a new source of truth
|
||||||
|
- a new persisted entity, table, or artifact
|
||||||
|
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||||
|
- a new enum, status family, reason code family, or lifecycle category
|
||||||
|
- a new cross-domain UI framework, taxonomy, or classification system
|
||||||
|
|
||||||
|
- **New source of truth?**: [yes/no]
|
||||||
|
- **New persisted entity/table/artifact?**: [yes/no]
|
||||||
|
- **New abstraction?**: [yes/no]
|
||||||
|
- **New enum/state/reason family?**: [yes/no]
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: [yes/no]
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk does this solve?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current implementation shape cannot safely or clearly solve it]
|
||||||
|
- **Narrowest correct implementation**: [Why this is the smallest viable solution]
|
||||||
|
- **Ownership cost**: [What maintenance, testing, review, migration, or conceptual cost this adds]
|
||||||
|
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -94,6 +165,23 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
|
||||||
|
new abstractions, new states, or new semantic layers, the spec MUST explain:
|
||||||
|
- which current operator workflow or current product truth requires the addition now,
|
||||||
|
- why a narrower implementation is insufficient,
|
||||||
|
- whether the addition is current-release truth or future-release preparation,
|
||||||
|
- what ownership cost it creates,
|
||||||
|
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
|
||||||
|
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||||
|
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||||
|
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||||
|
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||||
|
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
|
||||||
|
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
|
||||||
|
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
||||||
- ensure any cross-plane access is deny-as-not-found (404),
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
@ -112,10 +200,91 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
|
- the target object,
|
||||||
|
- the operator verb,
|
||||||
|
- whether source/domain disambiguation is actually needed,
|
||||||
|
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||||
|
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
|
||||||
|
- whether each affected surface is a Primary Decision Surface,
|
||||||
|
Secondary Context Surface, or Tertiary Evidence / Diagnostics
|
||||||
|
Surface, and why,
|
||||||
|
- which human-in-the-loop moment each primary surface supports,
|
||||||
|
- what MUST be visible immediately for the first decision,
|
||||||
|
- what is preserved but only revealed on demand,
|
||||||
|
- why any new primary surface cannot live inside an existing decision
|
||||||
|
context,
|
||||||
|
- how navigation follows operator workflows rather than storage
|
||||||
|
structures,
|
||||||
|
- how one governance case remains decidable in one focused context,
|
||||||
|
- how any new automation, notifications, or autonomous governance logic
|
||||||
|
reduce search/review/click load,
|
||||||
|
- and how the resulting default experience is calmer and clearer rather
|
||||||
|
than merely larger.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||||
|
- the chosen broad action-surface class and why it is the correct classification,
|
||||||
|
- the chosen detailed surface type and why it is the correct refinement,
|
||||||
|
- the one most likely next operator action,
|
||||||
|
- the one and only primary inspect/open model,
|
||||||
|
- whether row click is required, allowed, or forbidden,
|
||||||
|
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||||
|
- where pure navigation lives and why it is not competing with mutation,
|
||||||
|
- where secondary actions live,
|
||||||
|
- where destructive actions live,
|
||||||
|
- how grouped actions are ordered by meaning, frequency, and risk,
|
||||||
|
- the canonical collection route and canonical detail route,
|
||||||
|
- the scope signals shown to the operator and what real effect each one has,
|
||||||
|
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||||
|
- which critical operational truth is visible by default,
|
||||||
|
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** If this
|
||||||
|
feature adds or materially changes header actions, row actions, bulk
|
||||||
|
actions, or workbench controls, the spec MUST describe:
|
||||||
|
- how navigation, mutation, context signals, selection actions, and
|
||||||
|
dangerous actions are separated,
|
||||||
|
- why any visible secondary action deserves primary-plane placement,
|
||||||
|
- why any ActionGroup is structured rather than a mixed catch-all,
|
||||||
|
- and why any workflow-hub, wizard, system, or other special-type
|
||||||
|
exception is genuine rather than a convenience shortcut.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||||
|
- and the page contract for each new or materially refactored operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||||
|
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||||
|
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||||
|
- which existing layer is replaced or why no existing layer can serve,
|
||||||
|
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||||
|
- and how tests focus on business consequences rather than thin indirection alone.
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
|
The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent,
|
||||||
|
that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
|
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
|
||||||
|
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
|
||||||
<!--
|
<!--
|
||||||
ACTION REQUIRED: The content in this section represents placeholders.
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
Fill them out with the right functional requirements.
|
Fill them out with the right functional requirements.
|
||||||
@ -139,7 +308,7 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
|
|||||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
|||||||
@ -14,6 +14,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
||||||
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
||||||
without an `OperationRun`.
|
without an `OperationRun`.
|
||||||
|
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
|
||||||
|
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
|
||||||
|
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
|
||||||
|
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
|
||||||
|
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
|
||||||
|
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
|
||||||
|
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
|
||||||
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||||
- explicit 404 vs 403 semantics:
|
- explicit 404 vs 403 semantics:
|
||||||
@ -25,17 +32,95 @@ # Tasks: [FEATURE NAME]
|
|||||||
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||||
- at least one positive + one negative authorization test.
|
- at least one positive + one negative authorization test.
|
||||||
|
**UI Naming**: If this feature adds or changes operator-facing actions, run titles, notifications, audit prose, or helper copy, tasks MUST include:
|
||||||
|
- aligning primary action labels to `Verb + Object`,
|
||||||
|
- keeping scope terms (`Workspace`, `Tenant`) out of primary action labels unless they are the actual target object,
|
||||||
|
- using source/domain terms only where same-screen disambiguation is required,
|
||||||
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
|
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||||
|
- classifying each affected surface as Primary Decision, Secondary
|
||||||
|
Context, or Tertiary Evidence / Diagnostics and keeping that role in
|
||||||
|
sync with the governing spec,
|
||||||
|
- defining the human-in-the-loop moment and justifying any new Primary
|
||||||
|
Decision Surface against existing decision contexts,
|
||||||
|
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||||
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
|
- keeping default-visible content limited to first-decision needs and
|
||||||
|
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||||
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
|
- keeping each governance case decidable in one focused context where
|
||||||
|
practical instead of forcing cross-page reconstruction,
|
||||||
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
|
- keeping canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
||||||
|
- keeping navigation aligned to operator workflows rather than storage
|
||||||
|
structures,
|
||||||
|
- ensuring new automation, alerts, or autonomous flows reduce
|
||||||
|
search/review/click load instead of adding noise, extra lists, or
|
||||||
|
extra detail work,
|
||||||
|
- preserving a calm, prioritized default state that distinguishes
|
||||||
|
actionable work from worth-watching context and reference-only
|
||||||
|
information,
|
||||||
|
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
||||||
|
- keeping standard CRUD / Registry rows scanable rather than prose-heavy,
|
||||||
|
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||||
|
- assigning exactly one broad action-surface class to every changed
|
||||||
|
operator-facing surface and keeping the detailed surface type in sync
|
||||||
|
with the spec,
|
||||||
|
- identifying the one likely next operator action for each changed
|
||||||
|
surface and shaping the visible hierarchy around it,
|
||||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
|
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
||||||
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination,
|
||||||
|
- keeping standard CRUD / Registry rows to inspect/open plus at most one inline safe shortcut,
|
||||||
|
- separating navigation from mutation so pure context changes do not
|
||||||
|
compete visually with state-changing actions,
|
||||||
|
- moving additional secondary actions into More or the detail header,
|
||||||
|
- ordering visible actions and grouped actions by meaning, frequency,
|
||||||
|
and risk rather than append order,
|
||||||
|
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||||
|
- ensuring workbench and monitoring surfaces separate scope/context,
|
||||||
|
selection actions, navigation, and object actions instead of mixing
|
||||||
|
them into one flat header zone,
|
||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
|
- 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,
|
||||||
|
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||||
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- documenting any workflow-hub, wizard, utility/system, or other
|
||||||
|
special-type exception in the spec/PR and adding dedicated test
|
||||||
|
coverage,
|
||||||
|
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||||
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
|
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||||
|
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||||
|
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||||
|
- enforcing ACTSURF-001 / HDR-001 action discipline: record/detail/edit
|
||||||
|
pages keep at most 1 visible primary header action; pure navigation
|
||||||
|
moves to contextual placement; destructive or governance-changing
|
||||||
|
actions are separated and require friction; monitoring/workbench
|
||||||
|
surfaces use their own layered hierarchy; rare actions live in
|
||||||
|
structured Action Groups; every affected surface passes the few-second
|
||||||
|
scan rule,
|
||||||
|
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||||
|
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||||
|
**Proportionality / Anti-Bloat**: If this feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact,
|
||||||
|
interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework, tasks MUST include:
|
||||||
|
- completing the spec’s Proportionality Review,
|
||||||
|
- implementing the narrowest correct shape justified by current-release truth,
|
||||||
|
- removing or replacing superseded layers where practical instead of stacking new ones on top,
|
||||||
|
- keeping convenience projections and UI helpers derived unless independent persistence is explicitly justified,
|
||||||
|
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
@ -182,6 +267,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
|||||||
- [ ] TXXX Performance optimization across all stories
|
- [ ] TXXX Performance optimization across all stories
|
||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
424
Agents.md
424
Agents.md
@ -25,12 +25,14 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/memory/constitution.md`
|
||||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
|
Any spec that introduces a new persisted entity, abstraction, enum/status family, or taxonomy/framework must include the proportionality review required by the constitution before implementation starts.
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
|
|
||||||
## Workflow (SDD in diesem Repo)
|
## Workflow (SDD in diesem Repo)
|
||||||
@ -316,12 +318,13 @@ ## Security
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Sail (preferred locally)
|
### Sail (preferred locally)
|
||||||
- `./vendor/bin/sail up -d`
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
- `./vendor/bin/sail down`
|
- `cd apps/platform && ./vendor/bin/sail down`
|
||||||
- `./vendor/bin/sail composer install`
|
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- `./vendor/bin/sail artisan migrate`
|
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- `./vendor/bin/sail artisan test`
|
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||||
- `./vendor/bin/sail artisan` (general)
|
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||||
|
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||||
|
|
||||||
### Drizzle (local DB tooling, if configured)
|
### Drizzle (local DB tooling, if configured)
|
||||||
- Use only for local/dev workflows.
|
- Use only for local/dev workflows.
|
||||||
@ -333,10 +336,10 @@ ### Drizzle (local DB tooling, if configured)
|
|||||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||||
|
|
||||||
### Non-Docker fallback (only if needed)
|
### Non-Docker fallback (only if needed)
|
||||||
- `composer install`
|
- `cd apps/platform && composer install`
|
||||||
- `php artisan serve`
|
- `cd apps/platform && php artisan serve`
|
||||||
- `php artisan migrate`
|
- `cd apps/platform && php artisan migrate`
|
||||||
- `php artisan test`
|
- `cd apps/platform && php artisan test`
|
||||||
|
|
||||||
### Frontend/assets/tooling (if present)
|
### Frontend/assets/tooling (if present)
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
@ -350,11 +353,11 @@ ## Where to look first
|
|||||||
- `.specify/`
|
- `.specify/`
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `app/`
|
- `apps/platform/app/`
|
||||||
- `database/`
|
- `apps/platform/database/`
|
||||||
- `routes/`
|
- `apps/platform/routes/`
|
||||||
- `resources/`
|
- `apps/platform/resources/`
|
||||||
- `config/`
|
- `apps/platform/config/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -389,6 +392,7 @@ ## Reference Materials
|
|||||||
=== .ai/filament-v5-blueprint rules ===
|
=== .ai/filament-v5-blueprint rules ===
|
||||||
|
|
||||||
## Source of Truth
|
## Source of Truth
|
||||||
|
|
||||||
If any Filament behavior is uncertain, lookup the exact section in:
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
- docs/research/filament-v5-notes.md
|
- docs/research/filament-v5-notes.md
|
||||||
and prefer that over guesses.
|
and prefer that over guesses.
|
||||||
@ -398,6 +402,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
|||||||
# Filament Blueprint (v5)
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
## 1) Non-negotiables
|
## 1) Non-negotiables
|
||||||
|
|
||||||
- Filament v5 requires Livewire v4.0+.
|
- Filament v5 requires Livewire v4.0+.
|
||||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||||
@ -413,6 +418,7 @@ ## 1) Non-negotiables
|
|||||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
## 2) Directory & naming conventions
|
## 2) Directory & naming conventions
|
||||||
|
|
||||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||||
|
|
||||||
@ -421,19 +427,21 @@ ## 2) Directory & naming conventions
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
## 3) Panel setup defaults
|
## 3) Panel setup defaults
|
||||||
|
|
||||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 4) Navigation & information architecture
|
## 4) Navigation & information architecture
|
||||||
|
|
||||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
@ -447,6 +455,7 @@ ## 4) Navigation & information architecture
|
|||||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
## 5) Resource patterns
|
## 5) Resource patterns
|
||||||
|
|
||||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
- Global search:
|
- Global search:
|
||||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
@ -459,6 +468,7 @@ ## 5) Resource patterns
|
|||||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
## 6) Page lifecycle & query rules
|
## 6) Page lifecycle & query rules
|
||||||
|
|
||||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||||
|
|
||||||
@ -467,6 +477,7 @@ ## 6) Page lifecycle & query rules
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 7) Infolists vs RelationManagers (decision tree)
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
|
||||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
- Inline CRUD inside owner form → Repeater.
|
- Inline CRUD inside owner form → Repeater.
|
||||||
@ -477,6 +488,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
|
|||||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
## 8) Form patterns (validation, reactivity, state)
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
|
||||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||||
- Custom field views must obey state binding modifiers.
|
- Custom field views must obey state binding modifiers.
|
||||||
@ -486,6 +498,7 @@ ## 8) Form patterns (validation, reactivity, state)
|
|||||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
## 9) Table & action patterns
|
## 9) Table & action patterns
|
||||||
|
|
||||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Actions:
|
- Actions:
|
||||||
- Execution actions use `->action(...)`.
|
- Execution actions use `->action(...)`.
|
||||||
@ -498,6 +511,7 @@ ## 9) Table & action patterns
|
|||||||
- https://filamentphp.com/docs/5.x/actions/modals
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
## 10) Authorization & security
|
## 10) Authorization & security
|
||||||
|
|
||||||
- Enforce panel access in non-local environments as documented.
|
- Enforce panel access in non-local environments as documented.
|
||||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||||
@ -507,6 +521,7 @@ ## 10) Authorization & security
|
|||||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
## 11) Notifications & UX feedback
|
## 11) Notifications & UX feedback
|
||||||
|
|
||||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
@ -515,6 +530,7 @@ ## 11) Notifications & UX feedback
|
|||||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
## 12) Performance defaults
|
## 12) Performance defaults
|
||||||
|
|
||||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||||
|
|
||||||
@ -524,6 +540,7 @@ ## 12) Performance defaults
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 13) Testing requirements
|
## 13) Testing requirements
|
||||||
|
|
||||||
- Test pages/relation managers/widgets as Livewire components.
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
- Test actions using Filament’s action testing guidance.
|
- Test actions using Filament’s action testing guidance.
|
||||||
- Do not mount non-Livewire classes in Livewire tests.
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
@ -533,6 +550,7 @@ ## 13) Testing requirements
|
|||||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
## 14) Forbidden patterns
|
## 14) Forbidden patterns
|
||||||
|
|
||||||
- Mixing Filament v3/v4 APIs into v5 code.
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
- Any mention of Livewire v3 for Filament v5.
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
@ -547,6 +565,7 @@ ## 14) Forbidden patterns
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 15) Agent output contract
|
## 15) Agent output contract
|
||||||
|
|
||||||
For any implementation request, the agent must explicitly state:
|
For any implementation request, the agent must explicitly state:
|
||||||
1) Livewire v4.0+ compliance.
|
1) Livewire v4.0+ compliance.
|
||||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
@ -567,6 +586,7 @@ ## 15) Agent output contract
|
|||||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
## Version Safety
|
## Version Safety
|
||||||
|
|
||||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||||
@ -574,6 +594,7 @@ ## Version Safety
|
|||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
## Panel & Navigation
|
## Panel & Navigation
|
||||||
|
|
||||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
@ -588,6 +609,7 @@ ## Panel & Navigation
|
|||||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
## Resource Structure
|
## Resource Structure
|
||||||
|
|
||||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||||
@ -596,18 +618,21 @@ ## Resource Structure
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
## Infolists & Relations
|
## Infolists & Relations
|
||||||
|
|
||||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
## Forms
|
## Forms
|
||||||
|
|
||||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||||
|
|
||||||
## Tables & Actions
|
## Tables & Actions
|
||||||
|
|
||||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
@ -616,6 +641,7 @@ ## Tables & Actions
|
|||||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
## Authorization & Security
|
## Authorization & Security
|
||||||
|
|
||||||
- [ ] Panel access is enforced for non-local environments as documented.
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||||
@ -623,34 +649,39 @@ ## Authorization & Security
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
## UX & Notifications
|
## UX & Notifications
|
||||||
|
|
||||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
|
||||||
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||||
|
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.15
|
- php - 8.4.15
|
||||||
@ -666,56 +697,75 @@ ## Foundational Context
|
|||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## Skills Activation
|
||||||
|
|
||||||
|
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
|
- `pest-testing` — 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.
|
||||||
|
- `tailwindcss-development` — 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.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
## Verification Scripts
|
## Verification Scripts
|
||||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||||
|
|
||||||
## Application Structure & Architecture
|
## Application Structure & Architecture
|
||||||
|
|
||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
|
||||||
|
|
||||||
## Replies
|
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
|
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
# Laravel Boost
|
||||||
|
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
## Tinker / Debugging
|
## Tinker / Debugging
|
||||||
|
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
- Use the `database-query` tool when you only need to read from the database.
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||||
|
|
||||||
## Reading Browser Logs With the `browser-logs` Tool
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
- Only recent browser logs will be useful - ignore old logs.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## Searching Documentation (Critically Important)
|
||||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
|
||||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
|
||||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
### Available Search Syntax
|
### Available Search Syntax
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
|
||||||
|
|
||||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
@ -725,65 +775,72 @@ ### Available Search Syntax
|
|||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
## PHP
|
# PHP
|
||||||
|
|
||||||
- Always use curly braces for control structures, even if it has one line.
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- `public function __construct(public GitHub $github) { }`
|
||||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
### Type Declarations
|
## Type Declarations
|
||||||
|
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
- Use appropriate PHP type hints for method parameters.
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
<!-- Explicit Return Types and Method Params -->
|
||||||
|
```php
|
||||||
protected function isAccessible(User $user, ?string $path = null): bool
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
</code-snippet>
|
```
|
||||||
|
|
||||||
## Comments
|
|
||||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
|
||||||
|
|
||||||
## PHPDoc Blocks
|
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
|
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
|
||||||
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
=== sail rules ===
|
=== sail rules ===
|
||||||
|
|
||||||
## Laravel Sail
|
# Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
# Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
@ -791,43 +848,53 @@ ### Database
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
### Controllers & Validation
|
## Controllers & Validation
|
||||||
|
|
||||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
### Queues
|
## Authentication & Authorization
|
||||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
|
||||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
### URL Generation
|
## URL Generation
|
||||||
|
|
||||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
### Configuration
|
## Queues
|
||||||
|
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
### Testing
|
## Testing
|
||||||
|
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
### Vite Error
|
## Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
## Laravel 12
|
# Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version-specific documentation.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
### Laravel 12 Structure
|
## Laravel 12 Structure
|
||||||
|
|
||||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
@ -835,224 +902,39 @@ ### Laravel 12 Structure
|
|||||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
=== livewire/core rules ===
|
|
||||||
|
|
||||||
## Livewire
|
|
||||||
|
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
|
||||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
|
||||||
- State should live on the server, with the UI reflecting it.
|
|
||||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
|
||||||
|
|
||||||
## Livewire Best Practices
|
|
||||||
- Livewire components require a single root element.
|
|
||||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
|
||||||
- Add `wire:key` in loops:
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@foreach ($items as $item)
|
|
||||||
<div wire:key="item-{{ $item->id }}">
|
|
||||||
{{ $item->name }}
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
```
|
|
||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
|
||||||
|
|
||||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
|
||||||
public function mount(User $user) { $this->user = $user; }
|
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
## Testing Livewire
|
|
||||||
|
|
||||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('count', 0)
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', 1)
|
|
||||||
->assertSee(1)
|
|
||||||
->assertStatus(200);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
|
||||||
$this->get('/posts/create')
|
|
||||||
->assertSeeLivewire(CreatePost::class);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
### Testing
|
|
||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
|
||||||
|
|
||||||
### Pest Tests
|
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
- Do NOT delete tests without approval.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||||
- Pest tests look and behave like this:
|
|
||||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
|
||||||
it('is true', function () {
|
|
||||||
expect(true)->toBeTrue();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
|
||||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
|
||||||
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
|
||||||
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
|
||||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
|
||||||
|
|
||||||
### Pest Assertions
|
|
||||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
|
||||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
|
||||||
it('returns all', function () {
|
|
||||||
$response = $this->postJson('/api/docs', []);
|
|
||||||
|
|
||||||
$response->assertSuccessful();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Mocking
|
|
||||||
- Mocking can be very helpful when appropriate.
|
|
||||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
|
||||||
- You can also create partial mocks using the same import or self method.
|
|
||||||
|
|
||||||
### Datasets
|
|
||||||
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
|
||||||
|
|
||||||
<code-snippet name="Pest Dataset Example" lang="php">
|
|
||||||
it('has emails', function (string $email) {
|
|
||||||
expect($email)->not->toBeEmpty();
|
|
||||||
})->with([
|
|
||||||
'james' => 'james@laravel.com',
|
|
||||||
'taylor' => 'taylor@laravel.com',
|
|
||||||
]);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pest/v4 rules ===
|
|
||||||
|
|
||||||
## Pest 4
|
|
||||||
|
|
||||||
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
|
||||||
- Browser testing is incredibly powerful and useful for this project.
|
|
||||||
- Browser tests should live in `tests/Browser/`.
|
|
||||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
|
||||||
|
|
||||||
### Browser Testing
|
|
||||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
|
||||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
|
||||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
|
||||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
|
||||||
- Switch color schemes (light/dark mode) when appropriate.
|
|
||||||
- Take screenshots or pause tests for debugging when appropriate.
|
|
||||||
|
|
||||||
### Example Tests
|
|
||||||
|
|
||||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
|
||||||
it('may reset the password', function () {
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$this->actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$page = visit('/sign-in'); // Visit on a real browser...
|
|
||||||
|
|
||||||
$page->assertSee('Sign In')
|
|
||||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
|
||||||
->click('Forgot Password?')
|
|
||||||
->fill('email', 'nuno@laravel.com')
|
|
||||||
->click('Send Reset Link')
|
|
||||||
->assertSee('We have emailed your password reset link!')
|
|
||||||
|
|
||||||
Notification::assertSent(ResetPassword::class);
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
|
||||||
$pages = visit(['/', '/about', '/contact']);
|
|
||||||
|
|
||||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
## Tailwind CSS
|
# Tailwind CSS
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
|
||||||
|
|
||||||
### Spacing
|
|
||||||
- When listing items, use gap utilities for spacing; don't use margins.
|
|
||||||
|
|
||||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Superior</div>
|
|
||||||
<div>Michigan</div>
|
|
||||||
<div>Erie</div>
|
|
||||||
</div>
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Dark Mode
|
|
||||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
|
||||||
|
|
||||||
=== tailwindcss/v4 rules ===
|
|
||||||
|
|
||||||
## Tailwind CSS 4
|
|
||||||
|
|
||||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
|
||||||
|
|
||||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
|
||||||
@theme {
|
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
|
||||||
}
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
|
||||||
- Opacity values are still 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 |
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
|
|||||||
420
GEMINI.md
420
GEMINI.md
@ -156,12 +156,13 @@ ## Security
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Sail (preferred locally)
|
### Sail (preferred locally)
|
||||||
- `./vendor/bin/sail up -d`
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
- `./vendor/bin/sail down`
|
- `cd apps/platform && ./vendor/bin/sail down`
|
||||||
- `./vendor/bin/sail composer install`
|
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- `./vendor/bin/sail artisan migrate`
|
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- `./vendor/bin/sail artisan test`
|
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||||
- `./vendor/bin/sail artisan` (general)
|
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||||
|
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||||
|
|
||||||
### Drizzle (local DB tooling, if configured)
|
### Drizzle (local DB tooling, if configured)
|
||||||
- Use only for local/dev workflows.
|
- Use only for local/dev workflows.
|
||||||
@ -173,10 +174,10 @@ ### Drizzle (local DB tooling, if configured)
|
|||||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||||
|
|
||||||
### Non-Docker fallback (only if needed)
|
### Non-Docker fallback (only if needed)
|
||||||
- `composer install`
|
- `cd apps/platform && composer install`
|
||||||
- `php artisan serve`
|
- `cd apps/platform && php artisan serve`
|
||||||
- `php artisan migrate`
|
- `cd apps/platform && php artisan migrate`
|
||||||
- `php artisan test`
|
- `cd apps/platform && php artisan test`
|
||||||
|
|
||||||
### Frontend/assets/tooling (if present)
|
### Frontend/assets/tooling (if present)
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
@ -190,11 +191,11 @@ ## Where to look first
|
|||||||
- `.specify/`
|
- `.specify/`
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `app/`
|
- `apps/platform/app/`
|
||||||
- `database/`
|
- `apps/platform/database/`
|
||||||
- `routes/`
|
- `apps/platform/routes/`
|
||||||
- `resources/`
|
- `apps/platform/resources/`
|
||||||
- `config/`
|
- `apps/platform/config/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -229,6 +230,7 @@ ## Reference Materials
|
|||||||
=== .ai/filament-v5-blueprint rules ===
|
=== .ai/filament-v5-blueprint rules ===
|
||||||
|
|
||||||
## Source of Truth
|
## Source of Truth
|
||||||
|
|
||||||
If any Filament behavior is uncertain, lookup the exact section in:
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
- docs/research/filament-v5-notes.md
|
- docs/research/filament-v5-notes.md
|
||||||
and prefer that over guesses.
|
and prefer that over guesses.
|
||||||
@ -238,6 +240,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
|||||||
# Filament Blueprint (v5)
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
## 1) Non-negotiables
|
## 1) Non-negotiables
|
||||||
|
|
||||||
- Filament v5 requires Livewire v4.0+.
|
- Filament v5 requires Livewire v4.0+.
|
||||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||||
@ -253,6 +256,7 @@ ## 1) Non-negotiables
|
|||||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
## 2) Directory & naming conventions
|
## 2) Directory & naming conventions
|
||||||
|
|
||||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||||
|
|
||||||
@ -261,19 +265,21 @@ ## 2) Directory & naming conventions
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
## 3) Panel setup defaults
|
## 3) Panel setup defaults
|
||||||
|
|
||||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 4) Navigation & information architecture
|
## 4) Navigation & information architecture
|
||||||
|
|
||||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
@ -287,6 +293,7 @@ ## 4) Navigation & information architecture
|
|||||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
## 5) Resource patterns
|
## 5) Resource patterns
|
||||||
|
|
||||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
- Global search:
|
- Global search:
|
||||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
@ -299,6 +306,7 @@ ## 5) Resource patterns
|
|||||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
## 6) Page lifecycle & query rules
|
## 6) Page lifecycle & query rules
|
||||||
|
|
||||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||||
|
|
||||||
@ -307,6 +315,7 @@ ## 6) Page lifecycle & query rules
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 7) Infolists vs RelationManagers (decision tree)
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
|
||||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
- Inline CRUD inside owner form → Repeater.
|
- Inline CRUD inside owner form → Repeater.
|
||||||
@ -317,6 +326,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
|
|||||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
## 8) Form patterns (validation, reactivity, state)
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
|
||||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||||
- Custom field views must obey state binding modifiers.
|
- Custom field views must obey state binding modifiers.
|
||||||
@ -326,6 +336,7 @@ ## 8) Form patterns (validation, reactivity, state)
|
|||||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
## 9) Table & action patterns
|
## 9) Table & action patterns
|
||||||
|
|
||||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Actions:
|
- Actions:
|
||||||
- Execution actions use `->action(...)`.
|
- Execution actions use `->action(...)`.
|
||||||
@ -338,6 +349,7 @@ ## 9) Table & action patterns
|
|||||||
- https://filamentphp.com/docs/5.x/actions/modals
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
## 10) Authorization & security
|
## 10) Authorization & security
|
||||||
|
|
||||||
- Enforce panel access in non-local environments as documented.
|
- Enforce panel access in non-local environments as documented.
|
||||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||||
@ -347,6 +359,7 @@ ## 10) Authorization & security
|
|||||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
## 11) Notifications & UX feedback
|
## 11) Notifications & UX feedback
|
||||||
|
|
||||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
@ -355,6 +368,7 @@ ## 11) Notifications & UX feedback
|
|||||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
## 12) Performance defaults
|
## 12) Performance defaults
|
||||||
|
|
||||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||||
|
|
||||||
@ -364,6 +378,7 @@ ## 12) Performance defaults
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 13) Testing requirements
|
## 13) Testing requirements
|
||||||
|
|
||||||
- Test pages/relation managers/widgets as Livewire components.
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
- Test actions using Filament’s action testing guidance.
|
- Test actions using Filament’s action testing guidance.
|
||||||
- Do not mount non-Livewire classes in Livewire tests.
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
@ -373,6 +388,7 @@ ## 13) Testing requirements
|
|||||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
## 14) Forbidden patterns
|
## 14) Forbidden patterns
|
||||||
|
|
||||||
- Mixing Filament v3/v4 APIs into v5 code.
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
- Any mention of Livewire v3 for Filament v5.
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
@ -387,6 +403,7 @@ ## 14) Forbidden patterns
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 15) Agent output contract
|
## 15) Agent output contract
|
||||||
|
|
||||||
For any implementation request, the agent must explicitly state:
|
For any implementation request, the agent must explicitly state:
|
||||||
1) Livewire v4.0+ compliance.
|
1) Livewire v4.0+ compliance.
|
||||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
@ -407,6 +424,7 @@ ## 15) Agent output contract
|
|||||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
## Version Safety
|
## Version Safety
|
||||||
|
|
||||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||||
@ -414,6 +432,7 @@ ## Version Safety
|
|||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
## Panel & Navigation
|
## Panel & Navigation
|
||||||
|
|
||||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
@ -428,6 +447,7 @@ ## Panel & Navigation
|
|||||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
## Resource Structure
|
## Resource Structure
|
||||||
|
|
||||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||||
@ -436,18 +456,21 @@ ## Resource Structure
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
## Infolists & Relations
|
## Infolists & Relations
|
||||||
|
|
||||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
## Forms
|
## Forms
|
||||||
|
|
||||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||||
|
|
||||||
## Tables & Actions
|
## Tables & Actions
|
||||||
|
|
||||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
@ -456,6 +479,7 @@ ## Tables & Actions
|
|||||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
## Authorization & Security
|
## Authorization & Security
|
||||||
|
|
||||||
- [ ] Panel access is enforced for non-local environments as documented.
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||||
@ -463,34 +487,39 @@ ## Authorization & Security
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
## UX & Notifications
|
## UX & Notifications
|
||||||
|
|
||||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
|
||||||
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||||
|
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.15
|
- php - 8.4.15
|
||||||
@ -506,56 +535,75 @@ ## Foundational Context
|
|||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## Skills Activation
|
||||||
|
|
||||||
|
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
|
- `pest-testing` — 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.
|
||||||
|
- `tailwindcss-development` — 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.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
## Verification Scripts
|
## Verification Scripts
|
||||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||||
|
|
||||||
## Application Structure & Architecture
|
## Application Structure & Architecture
|
||||||
|
|
||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
|
||||||
|
|
||||||
## Replies
|
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
|
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
# Laravel Boost
|
||||||
|
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
## Tinker / Debugging
|
## Tinker / Debugging
|
||||||
|
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
- Use the `database-query` tool when you only need to read from the database.
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||||
|
|
||||||
## Reading Browser Logs With the `browser-logs` Tool
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
- Only recent browser logs will be useful - ignore old logs.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## Searching Documentation (Critically Important)
|
||||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
|
||||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
|
||||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
### Available Search Syntax
|
### Available Search Syntax
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
|
||||||
|
|
||||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
@ -565,65 +613,72 @@ ### Available Search Syntax
|
|||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
## PHP
|
# PHP
|
||||||
|
|
||||||
- Always use curly braces for control structures, even if it has one line.
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- `public function __construct(public GitHub $github) { }`
|
||||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
### Type Declarations
|
## Type Declarations
|
||||||
|
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
- Use appropriate PHP type hints for method parameters.
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
<!-- Explicit Return Types and Method Params -->
|
||||||
|
```php
|
||||||
protected function isAccessible(User $user, ?string $path = null): bool
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
</code-snippet>
|
```
|
||||||
|
|
||||||
## Comments
|
|
||||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
|
||||||
|
|
||||||
## PHPDoc Blocks
|
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
|
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
|
||||||
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
=== sail rules ===
|
=== sail rules ===
|
||||||
|
|
||||||
## Laravel Sail
|
# Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
# Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
@ -631,43 +686,53 @@ ### Database
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
### Controllers & Validation
|
## Controllers & Validation
|
||||||
|
|
||||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
### Queues
|
## Authentication & Authorization
|
||||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
|
||||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
### URL Generation
|
## URL Generation
|
||||||
|
|
||||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
### Configuration
|
## Queues
|
||||||
|
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
### Testing
|
## Testing
|
||||||
|
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
### Vite Error
|
## Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
## Laravel 12
|
# Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version-specific documentation.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
### Laravel 12 Structure
|
## Laravel 12 Structure
|
||||||
|
|
||||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
@ -675,224 +740,39 @@ ### Laravel 12 Structure
|
|||||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
=== livewire/core rules ===
|
|
||||||
|
|
||||||
## Livewire
|
|
||||||
|
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
|
||||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
|
||||||
- State should live on the server, with the UI reflecting it.
|
|
||||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
|
||||||
|
|
||||||
## Livewire Best Practices
|
|
||||||
- Livewire components require a single root element.
|
|
||||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
|
||||||
- Add `wire:key` in loops:
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@foreach ($items as $item)
|
|
||||||
<div wire:key="item-{{ $item->id }}">
|
|
||||||
{{ $item->name }}
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
```
|
|
||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
|
||||||
|
|
||||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
|
||||||
public function mount(User $user) { $this->user = $user; }
|
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
## Testing Livewire
|
|
||||||
|
|
||||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('count', 0)
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', 1)
|
|
||||||
->assertSee(1)
|
|
||||||
->assertStatus(200);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
|
||||||
$this->get('/posts/create')
|
|
||||||
->assertSeeLivewire(CreatePost::class);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
### Testing
|
|
||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
|
||||||
|
|
||||||
### Pest Tests
|
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
- Do NOT delete tests without approval.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||||
- Pest tests look and behave like this:
|
|
||||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
|
||||||
it('is true', function () {
|
|
||||||
expect(true)->toBeTrue();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
|
||||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
|
||||||
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
|
||||||
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
|
||||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
|
||||||
|
|
||||||
### Pest Assertions
|
|
||||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
|
||||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
|
||||||
it('returns all', function () {
|
|
||||||
$response = $this->postJson('/api/docs', []);
|
|
||||||
|
|
||||||
$response->assertSuccessful();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Mocking
|
|
||||||
- Mocking can be very helpful when appropriate.
|
|
||||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
|
||||||
- You can also create partial mocks using the same import or self method.
|
|
||||||
|
|
||||||
### Datasets
|
|
||||||
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
|
||||||
|
|
||||||
<code-snippet name="Pest Dataset Example" lang="php">
|
|
||||||
it('has emails', function (string $email) {
|
|
||||||
expect($email)->not->toBeEmpty();
|
|
||||||
})->with([
|
|
||||||
'james' => 'james@laravel.com',
|
|
||||||
'taylor' => 'taylor@laravel.com',
|
|
||||||
]);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pest/v4 rules ===
|
|
||||||
|
|
||||||
## Pest 4
|
|
||||||
|
|
||||||
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
|
||||||
- Browser testing is incredibly powerful and useful for this project.
|
|
||||||
- Browser tests should live in `tests/Browser/`.
|
|
||||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
|
||||||
|
|
||||||
### Browser Testing
|
|
||||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
|
||||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
|
||||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
|
||||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
|
||||||
- Switch color schemes (light/dark mode) when appropriate.
|
|
||||||
- Take screenshots or pause tests for debugging when appropriate.
|
|
||||||
|
|
||||||
### Example Tests
|
|
||||||
|
|
||||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
|
||||||
it('may reset the password', function () {
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$this->actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$page = visit('/sign-in'); // Visit on a real browser...
|
|
||||||
|
|
||||||
$page->assertSee('Sign In')
|
|
||||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
|
||||||
->click('Forgot Password?')
|
|
||||||
->fill('email', 'nuno@laravel.com')
|
|
||||||
->click('Send Reset Link')
|
|
||||||
->assertSee('We have emailed your password reset link!')
|
|
||||||
|
|
||||||
Notification::assertSent(ResetPassword::class);
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
|
||||||
$pages = visit(['/', '/about', '/contact']);
|
|
||||||
|
|
||||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
## Tailwind CSS
|
# Tailwind CSS
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
|
||||||
|
|
||||||
### Spacing
|
|
||||||
- When listing items, use gap utilities for spacing; don't use margins.
|
|
||||||
|
|
||||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Superior</div>
|
|
||||||
<div>Michigan</div>
|
|
||||||
<div>Erie</div>
|
|
||||||
</div>
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Dark Mode
|
|
||||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
|
||||||
|
|
||||||
=== tailwindcss/v4 rules ===
|
|
||||||
|
|
||||||
## Tailwind CSS 4
|
|
||||||
|
|
||||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
|
||||||
|
|
||||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
|
||||||
@theme {
|
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
|
||||||
}
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
|
||||||
- Opacity values are still 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 |
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|||||||
199
README.md
199
README.md
@ -1,19 +1,115 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# TenantPilot Workspace
|
||||||
|
|
||||||
<p align="center">
|
TenantPilot is an Intune management platform built around a stable Laravel application in
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
`apps/platform` and, starting with Spec 183, a standalone public Astro website in
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
`apps/website`. The repository root is now the official JavaScript workspace entry point and
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
orchestrates app-local commands without becoming a runtime itself.
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## TenantPilot setup
|
## Multi-App Topology
|
||||||
|
|
||||||
|
- `apps/platform`: the Laravel 12 + Filament v5 + Livewire v4 product runtime
|
||||||
|
- `apps/website`: the Astro v6 public website runtime
|
||||||
|
- repo root: workspace manifests, documentation, scripts, editor tooling, and `docker-compose.yml`
|
||||||
|
- `./scripts/platform-sail`: platform-only compatibility helper for tooling that cannot set `cwd`
|
||||||
|
|
||||||
|
## Official Root Commands
|
||||||
|
|
||||||
|
- Install workspace-managed JavaScript dependencies: `corepack pnpm install`
|
||||||
|
- Start the platform stack: `corepack pnpm dev:platform`
|
||||||
|
- Start the website dev server: `corepack pnpm dev:website`
|
||||||
|
- Start platform + website together: `corepack pnpm dev`
|
||||||
|
- Build the website: `corepack pnpm build:website`
|
||||||
|
- Build platform frontend assets: `corepack pnpm build:platform`
|
||||||
|
|
||||||
|
## App-Local Commands
|
||||||
|
|
||||||
|
### Platform
|
||||||
|
|
||||||
|
- Install PHP dependencies: `cd apps/platform && composer install`
|
||||||
|
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||||
|
- Run migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||||
|
- Run frontend watch/build inside Sail: `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`
|
||||||
|
|
||||||
|
### Website
|
||||||
|
|
||||||
|
- Start the dev server: `cd apps/website && pnpm dev`
|
||||||
|
- Build the static site: `cd apps/website && pnpm build`
|
||||||
|
|
||||||
|
## Test Suite Governance
|
||||||
|
|
||||||
|
### Canonical Lane Commands
|
||||||
|
|
||||||
|
- Preferred repo-root wrappers:
|
||||||
|
- `./scripts/platform-test-lane fast-feedback`
|
||||||
|
- `./scripts/platform-test-lane confidence`
|
||||||
|
- `./scripts/platform-test-lane heavy-governance`
|
||||||
|
- `./scripts/platform-test-lane browser`
|
||||||
|
- `./scripts/platform-test-lane profiling`
|
||||||
|
- `./scripts/platform-test-lane junit`
|
||||||
|
- Regenerate the latest report artifacts without re-running the lane:
|
||||||
|
- `./scripts/platform-test-report fast-feedback`
|
||||||
|
- `./scripts/platform-test-report confidence`
|
||||||
|
- `./scripts/platform-test-report heavy-governance`
|
||||||
|
- `./scripts/platform-test-report browser`
|
||||||
|
- `./scripts/platform-test-report profiling`
|
||||||
|
- `./scripts/platform-test-report junit`
|
||||||
|
- App-local equivalents remain available through Sail Composer scripts:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail composer run test`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail composer run test:confidence`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail composer run test:heavy`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail composer run test:browser`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail composer run test:profile`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail composer run test:junit`
|
||||||
|
- The root wrapper is the safer default for long lanes because it pins Composer to `--timeout=0`.
|
||||||
|
|
||||||
|
### Recorded Baselines
|
||||||
|
|
||||||
|
| Scope | Wall clock | Budget | Notes |
|
||||||
|
|-------|------------|--------|-------|
|
||||||
|
| Full suite baseline | `2624.60s` | reference only | Current broad-suite measurement used as the budget anchor |
|
||||||
|
| `fast-feedback` | `176.74s` | `200s` | More than 50% below the current full-suite baseline |
|
||||||
|
| `confidence` | `394.38s` | `450s` | Broader non-browser pre-merge lane |
|
||||||
|
| `heavy-governance` | `83.66s` | `120s` | Seed heavy family lane for architecture, deprecation, ops UX, and action-surface scans |
|
||||||
|
| `browser` | `128.87s` | `150s` | Dedicated browser smoke and workflow lane |
|
||||||
|
| `junit` | `380.14s` | `450s` | Parallel machine-readable report lane for the confidence scope |
|
||||||
|
| `profiling` | `2701.51s` | `3000s` | Serial slow-test drift lane with profile output |
|
||||||
|
|
||||||
|
Artifacts are written under `apps/platform/storage/logs/test-lanes` and kept out of git except for the checked-in skeleton `.gitignore`.
|
||||||
|
|
||||||
|
### Honest Taxonomy Rules
|
||||||
|
|
||||||
|
- `Unit`: isolated logic, helpers, and low-cost domain behavior.
|
||||||
|
- `Feature`: HTTP, Livewire, Filament, jobs, and non-browser integration slices.
|
||||||
|
- `Browser`: only end-to-end browser smoke and workflow coverage under `tests/Browser`.
|
||||||
|
- `heavy-governance`: intentionally expensive architecture, deprecation, ops UX, and wide contract scans. The first seeded batch is `tests/Architecture`, `tests/Deprecation`, `tests/Feature/078`, `tests/Feature/090`, `tests/Feature/144`, `tests/Feature/OpsUx`, `tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, and `tests/Feature/ProviderConnections/CredentialLeakGuardTest.php`.
|
||||||
|
|
||||||
|
### Fixture Cost Guidance
|
||||||
|
|
||||||
|
- `createUserWithTenant()` now defaults to the explicit cheap `minimal` profile.
|
||||||
|
- Use `createMinimalUserWithTenant()` in high-usage callers that only need tenant membership and workspace/session wiring.
|
||||||
|
- Use `createStandardUserWithTenant()` or `fixtureProfile: 'standard'` when a test needs a default Microsoft provider connection without credentials, cache resets, or UI context.
|
||||||
|
- Use `createFullUserWithTenant()` or `fixtureProfile: 'full'` when a test intentionally needs provider, credential, cache-reset, and UI-context side effects together.
|
||||||
|
- Use `OperationRun::factory()->minimal()` for system-style runs and `OperationRun::factory()->withUser($user)` only when the initiator identity is materially part of the assertion.
|
||||||
|
- Use `BackupSet::factory()->full()` only when the test really needs backup items; the default backup-set factory path now stays item-free.
|
||||||
|
- `provider-enabled`, `credential-enabled`, `ui-context`, and `heavy` remain available only as temporary transition aliases while the first migration packs are landing.
|
||||||
|
|
||||||
|
### DB Reset and Seed Rules
|
||||||
|
|
||||||
|
- Default lanes use SQLite `:memory:` with `RefreshDatabase` as the reset strategy.
|
||||||
|
- The isolated PostgreSQL coverage remains the `Pgsql` suite and is reserved for schema or foreign-key assertions.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Port Overrides
|
||||||
|
|
||||||
|
- Platform HTTP and Vite ports: set `APP_PORT` and or `VITE_PORT` before `corepack pnpm dev:platform` or `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Website dev server port: set `WEBSITE_PORT` before `corepack pnpm dev:website` or pass `--port <port>` to `cd apps/website && pnpm dev`
|
||||||
|
- Parallel local development keeps both apps isolated, even when one or both ports are overridden
|
||||||
|
|
||||||
|
## Platform Setup Notes
|
||||||
|
|
||||||
- Local dev (Sail-first):
|
|
||||||
- Start stack: `./vendor/bin/sail up -d`
|
|
||||||
- Init DB: `./vendor/bin/sail artisan migrate --seed`
|
|
||||||
- Tests: `./vendor/bin/sail artisan test`
|
|
||||||
- Policy sync: `./vendor/bin/sail artisan intune:sync-policies`
|
|
||||||
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
||||||
- Microsoft Graph (Intune) env vars:
|
- Microsoft Graph (Intune) env vars:
|
||||||
- `GRAPH_TENANT_ID`
|
- `GRAPH_TENANT_ID`
|
||||||
@ -25,10 +121,17 @@ ## TenantPilot setup
|
|||||||
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
||||||
- Deployment (Dokploy, staging → production):
|
- Deployment (Dokploy, staging → production):
|
||||||
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
||||||
|
- Run application commands from `apps/platform`, including `php artisan filament:assets`.
|
||||||
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
||||||
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
||||||
- Keep secrets/env in Dokploy, never in code.
|
- Keep secrets/env in Dokploy, never in code.
|
||||||
|
|
||||||
|
## Platform relocation rollout notes
|
||||||
|
|
||||||
|
- Open branches that still touch legacy root app paths should merge `dev` first, then remap file moves from `app/`, `bootstrap/`, `config/`, `database/`, `lang/`, `public/`, `resources/`, `routes/`, `storage/`, and `tests/` into `apps/platform/...`.
|
||||||
|
- Keep using merge-based catch-up on shared feature branches; do not rebase long-lived shared branches just to absorb the relocation.
|
||||||
|
- VS Code tasks expose the official root workspace commands, while MCP launchers remain platform-only and delegate through `./scripts/platform-sail`.
|
||||||
|
|
||||||
## Bulk operations (Feature 005)
|
## Bulk operations (Feature 005)
|
||||||
|
|
||||||
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
||||||
@ -39,8 +142,23 @@ ### Troubleshooting
|
|||||||
|
|
||||||
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
||||||
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
||||||
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
|
- Check worker status/logs: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`.
|
||||||
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
||||||
|
- **Moved app but old commands still fail** usually means the command is still being run from repo root. Switch to `cd apps/platform && ...` or use `./scripts/platform-sail ...` only for tooling that cannot set `cwd`.
|
||||||
|
|
||||||
|
## Rollback checklist
|
||||||
|
|
||||||
|
1. Revert the relocation commit or merge on your feature branch instead of hard-resetting shared history.
|
||||||
|
2. Preserve any local app env overrides before switching commits: `cp apps/platform/.env /tmp/tenantatlas.platform.env.backup` if needed.
|
||||||
|
3. Stop local containers and clean generated artifacts: `cd apps/platform && ./vendor/bin/sail down -v`, then remove `apps/platform/vendor`, `apps/platform/node_modules`, `apps/platform/public/build`, and `apps/platform/public/hot` if they need a clean rebuild.
|
||||||
|
4. After rollback, restore the matching env file for the restored topology and rerun the documented setup flow for that commit.
|
||||||
|
5. Notify owners of open feature branches that the topology changed so they can remap outstanding work before the next merge from `dev`.
|
||||||
|
|
||||||
|
## Deployment unknowns
|
||||||
|
|
||||||
|
- Dokploy build context for a repo-root compose file plus an app-root Laravel runtime still needs staging confirmation.
|
||||||
|
- Production web, queue, and scheduler working directories must be verified explicitly after the move; do not assume repo root and app root behave interchangeably.
|
||||||
|
- Any Dokploy volume mounts or storage persistence paths that previously targeted repo-root `storage/` must be reviewed against `apps/platform/storage/`.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@ -64,7 +182,7 @@ ## Graph Contract Registry & Drift Guard
|
|||||||
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
||||||
- Derived @odata.type values within the family are accepted for preview/restore routing.
|
- Derived @odata.type values within the family are accepted for preview/restore routing.
|
||||||
- Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings.
|
- Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings.
|
||||||
- Drift check: `php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
- Drift check: `cd apps/platform && php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
||||||
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
||||||
|
|
||||||
## Policy Settings Display
|
## Policy Settings Display
|
||||||
@ -89,54 +207,3 @@ ## Policy JSON Viewer (Feature 002)
|
|||||||
- Scrollable container with max height to prevent page overflow
|
- Scrollable container with max height to prevent page overflow
|
||||||
- **Usage**: See `specs/002-filament-json/quickstart.md` for detailed examples and configuration
|
- **Usage**: See `specs/002-filament-json/quickstart.md` for detailed examples and configuration
|
||||||
- **Performance**: Optimized for payloads up to 1 MB; auto-collapse improves initial render for large snapshots
|
- **Performance**: Optimized for payloads up to 1 MB; auto-collapse improves initial render for large snapshots
|
||||||
|
|
||||||
## About Laravel
|
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
|
||||||
|
|
||||||
## Learning Laravel
|
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
|
||||||
|
|
||||||
## Laravel Sponsors
|
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
|
||||||
|
|
||||||
### Premium Partners
|
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\UserTenantPreference;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
class ChooseTenant extends Page
|
|
||||||
{
|
|
||||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'choose-tenant';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Choose tenant';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.choose-tenant';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function getTenants(): Collection
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
|
||||||
|
|
||||||
if ($tenants instanceof Collection) {
|
|
||||||
return $tenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect($tenants);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectTenant(int $tenantId): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->whereKey($tenantId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->persistLastTenant($user, $tenant);
|
|
||||||
|
|
||||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
|
||||||
{
|
|
||||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
|
||||||
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Schema::hasTable('user_tenant_preferences')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
UserTenantPreference::query()->updateOrCreate(
|
|
||||||
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
|
|
||||||
['last_used_at' => now()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Drift\DriftRunSelector;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class DriftLanding extends Page
|
|
||||||
{
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Drift';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.drift-landing';
|
|
||||||
|
|
||||||
public ?string $state = null;
|
|
||||||
|
|
||||||
public ?string $message = null;
|
|
||||||
|
|
||||||
public ?string $scopeKey = null;
|
|
||||||
|
|
||||||
public ?int $baselineRunId = null;
|
|
||||||
|
|
||||||
public ?int $currentRunId = null;
|
|
||||||
|
|
||||||
public ?string $baselineFinishedAt = null;
|
|
||||||
|
|
||||||
public ?string $currentFinishedAt = null;
|
|
||||||
|
|
||||||
public ?int $operationRunId = null;
|
|
||||||
|
|
||||||
/** @var array<string, int>|null */
|
|
||||||
public ?array $statusCounts = null;
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
return FindingResource::canAccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403, 'Not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestSuccessful = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->whereIn('outcome', [
|
|
||||||
OperationRunOutcome::Succeeded->value,
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
])
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestSuccessful instanceof OperationRun) {
|
|
||||||
$this->state = 'blocked';
|
|
||||||
$this->message = 'No successful inventory runs found yet.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : [];
|
|
||||||
$scopeKey = (string) ($latestContext['selection_hash'] ?? '');
|
|
||||||
|
|
||||||
if ($scopeKey === '') {
|
|
||||||
$this->state = 'blocked';
|
|
||||||
$this->message = 'No inventory scope key was found on the latest successful inventory run.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->scopeKey = $scopeKey;
|
|
||||||
|
|
||||||
$selector = app(DriftRunSelector::class);
|
|
||||||
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
|
||||||
|
|
||||||
if ($comparison === null) {
|
|
||||||
$this->state = 'blocked';
|
|
||||||
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$baseline = $comparison['baseline'];
|
|
||||||
$current = $comparison['current'];
|
|
||||||
|
|
||||||
$this->baselineRunId = (int) $baseline->getKey();
|
|
||||||
$this->currentRunId = (int) $current->getKey();
|
|
||||||
|
|
||||||
$this->baselineFinishedAt = $baseline->completed_at?->toDateTimeString();
|
|
||||||
$this->currentFinishedAt = $current->completed_at?->toDateTimeString();
|
|
||||||
|
|
||||||
$existingOperationRun = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'drift_generate_findings')
|
|
||||||
->where('context->scope_key', $scopeKey)
|
|
||||||
->where('context->baseline_operation_run_id', (int) $baseline->getKey())
|
|
||||||
->where('context->current_operation_run_id', (int) $current->getKey())
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existingOperationRun instanceof OperationRun) {
|
|
||||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
$exists = Finding::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('scope_key', $scopeKey)
|
|
||||||
->where('baseline_operation_run_id', $baseline->getKey())
|
|
||||||
->where('current_operation_run_id', $current->getKey())
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
$this->state = 'ready';
|
|
||||||
$newCount = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('scope_key', $scopeKey)
|
|
||||||
->where('baseline_operation_run_id', $baseline->getKey())
|
|
||||||
->where('current_operation_run_id', $current->getKey())
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingOperationRun?->refresh();
|
|
||||||
|
|
||||||
if ($existingOperationRun instanceof OperationRun
|
|
||||||
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
|
|
||||||
) {
|
|
||||||
$this->state = 'generating';
|
|
||||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existingOperationRun instanceof OperationRun
|
|
||||||
&& $existingOperationRun->status === 'completed'
|
|
||||||
) {
|
|
||||||
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
|
|
||||||
$created = (int) ($counts['created'] ?? 0);
|
|
||||||
|
|
||||||
if ($existingOperationRun->outcome === 'failed') {
|
|
||||||
$this->state = 'error';
|
|
||||||
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
|
||||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($created === 0) {
|
|
||||||
$this->state = 'ready';
|
|
||||||
$this->statusCounts = [Finding::STATUS_NEW => 0];
|
|
||||||
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
|
|
||||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
|
||||||
$this->state = 'blocked';
|
|
||||||
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
$selectionIdentity = $selection->fromQuery([
|
|
||||||
'scope_key' => $scopeKey,
|
|
||||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
|
||||||
'current_operation_run_id' => (int) $current->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $opService->enqueueBulkOperation(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'drift_generate_findings',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void {
|
|
||||||
GenerateDriftFindingsJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
baselineRunId: (int) $baseline->getKey(),
|
|
||||||
currentRunId: (int) $current->getKey(),
|
|
||||||
scopeKey: $scopeKey,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $user,
|
|
||||||
extraContext: [
|
|
||||||
'scope_key' => $scopeKey,
|
|
||||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
|
||||||
'current_operation_run_id' => (int) $current->getKey(),
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->operationRunId = (int) $opRun->getKey();
|
|
||||||
$this->state = 'generating';
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Drift generation already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFindingsUrl(): string
|
|
||||||
{
|
|
||||||
return FindingResource::getUrl('index', tenant: Tenant::current());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBaselineRunUrl(): ?string
|
|
||||||
{
|
|
||||||
if (! is_int($this->baselineRunId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return route('admin.operations.view', ['run' => $this->baselineRunId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCurrentRunUrl(): ?string
|
|
||||||
{
|
|
||||||
if (! is_int($this->currentRunId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return route('admin.operations.view', ['run' => $this->currentRunId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getOperationRunUrl(): ?string
|
|
||||||
{
|
|
||||||
if (! is_int($this->operationRunId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRunLinks::view($this->operationRunId, Tenant::current());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class InventoryCoverage extends Page
|
|
||||||
{
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 3;
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Coverage';
|
|
||||||
|
|
||||||
protected static ?string $cluster = InventoryCluster::class;
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-coverage';
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return $resolver->isMember($user, $tenant)
|
|
||||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
InventoryKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public array $supportedPolicyTypes = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public array $foundationTypes = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
|
||||||
|
|
||||||
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
|
|
||||||
->map(function (array $row) use ($resolver): array {
|
|
||||||
$type = (string) ($row['type'] ?? '');
|
|
||||||
|
|
||||||
return array_merge($row, [
|
|
||||||
'dependencies' => $type !== '' && $resolver->supportsDependencies($type),
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
|
|
||||||
->map(function (array $row): array {
|
|
||||||
return array_merge($row, [
|
|
||||||
'dependencies' => false,
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class Alerts extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Alerts';
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'alerts';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Alerts';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.alerts';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return app(OperateHubShell::class)->headerActions(
|
|
||||||
scopeActionName: 'operate_hub_scope_alerts',
|
|
||||||
returnActionName: 'operate_hub_return_alerts',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class AuditLog extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Audit Log';
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'audit-log';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Audit Log';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return app(OperateHubShell::class)->headerActions(
|
|
||||||
scopeActionName: 'operate_hub_scope_audit_log',
|
|
||||||
returnActionName: 'operate_hub_return_audit_log',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class Operations extends Page implements HasForms, HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
public string $activeTab = 'all';
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Operations';
|
|
||||||
|
|
||||||
// Must be non-static
|
|
||||||
protected string $view = 'filament.pages.monitoring.operations';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
OperationsKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$operateHubShell = app(OperateHubShell::class);
|
|
||||||
|
|
||||||
$actions = [
|
|
||||||
Action::make('operate_hub_scope_operations')
|
|
||||||
->label($operateHubShell->scopeLabel(request()))
|
|
||||||
->color('gray')
|
|
||||||
->disabled(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
|
||||||
->label('Back to '.$activeTenant->name)
|
|
||||||
->icon('heroicon-o-arrow-left')
|
|
||||||
->color('gray')
|
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
|
||||||
|
|
||||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
|
||||||
->label('Show all tenants')
|
|
||||||
->color('gray')
|
|
||||||
->action(function (): void {
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
|
||||||
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
|
|
||||||
$this->redirect('/admin/operations');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedActiveTab(): void
|
|
||||||
{
|
|
||||||
$this->resetPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return OperationRunResource::table($table)
|
|
||||||
->query(function (): Builder {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
$query = OperationRun::query()
|
|
||||||
->with('user')
|
|
||||||
->latest('id')
|
|
||||||
->when(
|
|
||||||
$workspaceId,
|
|
||||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
! $workspaceId,
|
|
||||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
$activeTenant instanceof Tenant,
|
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->applyActiveTab($query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyActiveTab(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return match ($this->activeTab) {
|
|
||||||
'active' => $query->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
]),
|
|
||||||
'succeeded' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
|
||||||
'partial' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
|
|
||||||
'failed' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Operations;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Schemas\Components\EmbeddedSchema;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class TenantlessOperationRunViewer extends Page
|
|
||||||
{
|
|
||||||
use AuthorizesRequests;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $title = 'Operation run';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
|
||||||
|
|
||||||
public OperationRun $run;
|
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action|ActionGroup>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$operateHubShell = app(OperateHubShell::class);
|
|
||||||
|
|
||||||
$actions = [
|
|
||||||
Action::make('operate_hub_scope_run_detail')
|
|
||||||
->label($operateHubShell->scopeLabel(request()))
|
|
||||||
->color('gray')
|
|
||||||
->disabled(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
|
||||||
->label('← Back to '.$activeTenant->name)
|
|
||||||
->color('gray')
|
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
|
||||||
|
|
||||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
|
||||||
->label('Show all operations')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => route('admin.operations.index'));
|
|
||||||
} else {
|
|
||||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
|
||||||
->label('Back to Operations')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => route('admin.operations.index'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Action::make('refresh')
|
|
||||||
->label('Refresh')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => isset($this->run)
|
|
||||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
|
||||||
: route('admin.operations.index'));
|
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
|
||||||
$tenant = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($this->run, $tenant);
|
|
||||||
|
|
||||||
$relatedActions = [];
|
|
||||||
|
|
||||||
foreach ($related as $label => $url) {
|
|
||||||
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
|
|
||||||
->label((string) $label)
|
|
||||||
->url((string) $url)
|
|
||||||
->openUrlInNewTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($relatedActions !== []) {
|
|
||||||
$actions[] = ActionGroup::make($relatedActions)
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorize('view', $run);
|
|
||||||
|
|
||||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return OperationRunResource::infolist($schema);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function defaultInfolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->record($this->run)
|
|
||||||
->columns(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
EmbeddedSchema::make('infolist'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
|
|
||||||
class TenantRequiredPermissions extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Required permissions';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.tenant-required-permissions';
|
|
||||||
|
|
||||||
public string $status = 'missing';
|
|
||||||
|
|
||||||
public string $type = 'all';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
public array $features = [];
|
|
||||||
|
|
||||||
public string $search = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, mixed>
|
|
||||||
*/
|
|
||||||
public array $viewModel = [];
|
|
||||||
|
|
||||||
public ?Tenant $scopedTenant = null;
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
return $this->scopedTenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$tenant = static::resolveScopedTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->scopedTenant = $tenant;
|
|
||||||
|
|
||||||
$queryFeatures = request()->query('features', $this->features);
|
|
||||||
|
|
||||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
|
||||||
'status' => request()->query('status', $this->status),
|
|
||||||
'type' => request()->query('type', $this->type),
|
|
||||||
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
|
||||||
'search' => request()->query('search', $this->search),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->status = $state['status'];
|
|
||||||
$this->type = $state['type'];
|
|
||||||
$this->features = $state['features'];
|
|
||||||
$this->search = $state['search'];
|
|
||||||
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedStatus(): void
|
|
||||||
{
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedType(): void
|
|
||||||
{
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedFeatures(): void
|
|
||||||
{
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSearch(): void
|
|
||||||
{
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyFeatureFilter(string $feature): void
|
|
||||||
{
|
|
||||||
$feature = trim($feature);
|
|
||||||
|
|
||||||
if ($feature === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($feature, $this->features, true)) {
|
|
||||||
$this->features = array_values(array_filter(
|
|
||||||
$this->features,
|
|
||||||
static fn (string $value): bool => $value !== $feature,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
$this->features[] = $feature;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->features = array_values(array_unique($this->features));
|
|
||||||
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearFeatureFilter(): void
|
|
||||||
{
|
|
||||||
$this->features = [];
|
|
||||||
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetFilters(): void
|
|
||||||
{
|
|
||||||
$this->status = 'missing';
|
|
||||||
$this->type = 'all';
|
|
||||||
$this->features = [];
|
|
||||||
$this->search = '';
|
|
||||||
|
|
||||||
$this->refreshViewModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function refreshViewModel(): void
|
|
||||||
{
|
|
||||||
$tenant = $this->scopedTenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
$this->viewModel = [];
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
|
||||||
|
|
||||||
$this->viewModel = $builder->build($tenant, [
|
|
||||||
'status' => $this->status,
|
|
||||||
'type' => $this->type,
|
|
||||||
'features' => $this->features,
|
|
||||||
'search' => $this->search,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$filters = $this->viewModel['filters'] ?? null;
|
|
||||||
|
|
||||||
if (is_array($filters)) {
|
|
||||||
$this->status = (string) ($filters['status'] ?? $this->status);
|
|
||||||
$this->type = (string) ($filters['type'] ?? $this->type);
|
|
||||||
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
|
|
||||||
$this->search = (string) ($filters['search'] ?? $this->search);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reRunVerificationUrl(): string
|
|
||||||
{
|
|
||||||
return route('admin.onboarding');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function manageProviderConnectionUrl(): ?string
|
|
||||||
{
|
|
||||||
$tenant = $this->scopedTenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function resolveScopedTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$routeTenant = request()->route('tenant');
|
|
||||||
|
|
||||||
if ($routeTenant instanceof Tenant) {
|
|
||||||
return $routeTenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('external_id', $routeTenant)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isWorkspaceMember = WorkspaceMembership::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $isWorkspaceMember) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListBackupSchedules extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupScheduleResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [$this->makeHeaderCreateAction()];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
return [$this->makeEmptyStateCreateAction()];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeHeaderCreateAction(): Actions\CreateAction
|
|
||||||
{
|
|
||||||
return $this->makeCreateAction()
|
|
||||||
->visible(fn (): bool => $this->tableHasRecords());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeEmptyStateCreateAction(): Actions\CreateAction
|
|
||||||
{
|
|
||||||
return $this->makeCreateAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeCreateAction(): Actions\CreateAction
|
|
||||||
{
|
|
||||||
return Actions\CreateAction::make()
|
|
||||||
->label('New backup schedule')
|
|
||||||
->disabled(fn (): bool => ! BackupScheduleResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => BackupScheduleResource::canCreate()
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to create backup schedules.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListBackupSets extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$create = Actions\CreateAction::make();
|
|
||||||
UiEnforcement::forAction($create)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
return [
|
|
||||||
$create->visible(fn (): bool => $this->tableHasRecords()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
$create = Actions\CreateAction::make();
|
|
||||||
UiEnforcement::forAction($create)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
return [
|
|
||||||
$create,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewBackupSet extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
|
||||||
}
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
|
||||||
use App\Models\EntraGroup;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class EntraGroupResource extends Resource
|
|
||||||
{
|
|
||||||
protected static bool $isScopedToTenant = false;
|
|
||||||
|
|
||||||
protected static ?string $model = EntraGroup::class;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Groups';
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Group')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('display_name')->label('Name'),
|
|
||||||
TextEntry::make('entra_id')->label('Entra ID')->copyable(),
|
|
||||||
TextEntry::make('type')
|
|
||||||
->badge()
|
|
||||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
|
|
||||||
TextEntry::make('security_enabled')
|
|
||||||
->label('Security')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
|
||||||
TextEntry::make('mail_enabled')
|
|
||||||
->label('Mail')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
|
||||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Raw groupTypes')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('group_types')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (EntraGroup $record) => $record->group_types ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('display_name')
|
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
|
||||||
})
|
|
||||||
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
|
|
||||||
? static::getUrl('view', ['record' => $record])
|
|
||||||
: null)
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
|
||||||
->label('Name')
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('entra_id')
|
|
||||||
->label('Entra ID')
|
|
||||||
->copyable()
|
|
||||||
->toggleable(),
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
|
||||||
->label('Type')
|
|
||||||
->badge()
|
|
||||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
|
|
||||||
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
|
|
||||||
Tables\Columns\TextColumn::make('last_seen_at')
|
|
||||||
->label('Last seen')
|
|
||||||
->since(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('stale')
|
|
||||||
->label('Stale')
|
|
||||||
->options([
|
|
||||||
'1' => 'Stale',
|
|
||||||
'0' => 'Fresh',
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$value = $data['value'] ?? null;
|
|
||||||
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
|
||||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
|
||||||
|
|
||||||
if ((string) $value === '1') {
|
|
||||||
return $query->where(function (Builder $q) use ($cutoff): void {
|
|
||||||
$q->whereNull('last_seen_at')
|
|
||||||
->orWhere('last_seen_at', '<', $cutoff);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where('last_seen_at', '>=', $cutoff);
|
|
||||||
}),
|
|
||||||
|
|
||||||
SelectFilter::make('group_type')
|
|
||||||
->label('Type')
|
|
||||||
->options([
|
|
||||||
'security' => 'Security',
|
|
||||||
'microsoft365' => 'Microsoft 365',
|
|
||||||
'mail' => 'Mail-enabled',
|
|
||||||
'unknown' => 'Unknown',
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$value = (string) ($data['value'] ?? '');
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($value) {
|
|
||||||
'microsoft365' => $query->whereJsonContains('group_types', 'Unified'),
|
|
||||||
'security' => $query
|
|
||||||
->where('security_enabled', true)
|
|
||||||
->where(function (Builder $q): void {
|
|
||||||
$q->whereNull('group_types')
|
|
||||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
|
||||||
}),
|
|
||||||
'mail' => $query
|
|
||||||
->where('mail_enabled', true)
|
|
||||||
->where(function (Builder $q): void {
|
|
||||||
$q->whereNull('group_types')
|
|
||||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
|
||||||
}),
|
|
||||||
'unknown' => $query
|
|
||||||
->where(function (Builder $q): void {
|
|
||||||
$q->whereNull('group_types')
|
|
||||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
|
||||||
})
|
|
||||||
->where('security_enabled', false)
|
|
||||||
->where('mail_enabled', false),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([])
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return parent::getEloquentQuery()->latest('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListEntraGroups::route('/'),
|
|
||||||
'view' => Pages\ViewEntraGroup::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function groupType(EntraGroup $record): string
|
|
||||||
{
|
|
||||||
$groupTypes = $record->group_types;
|
|
||||||
|
|
||||||
if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) {
|
|
||||||
return 'microsoft365';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->security_enabled) {
|
|
||||||
return 'security';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->mail_enabled) {
|
|
||||||
return 'mail';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function groupTypeLabel(string $type): string
|
|
||||||
{
|
|
||||||
return match ($type) {
|
|
||||||
'microsoft365' => 'Microsoft 365',
|
|
||||||
'security' => 'Security',
|
|
||||||
'mail' => 'Mail-enabled',
|
|
||||||
default => 'Unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function groupTypeColor(string $type): string
|
|
||||||
{
|
|
||||||
return match ($type) {
|
|
||||||
'microsoft365' => 'info',
|
|
||||||
'security' => 'success',
|
|
||||||
'mail' => 'warning',
|
|
||||||
default => 'gray',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewEntraGroup extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EntraGroupResource::class;
|
|
||||||
}
|
|
||||||
@ -1,464 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource\Pages;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\InventoryItem;
|
|
||||||
use App\Models\PolicyVersion;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Actions\BulkAction;
|
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = Finding::class;
|
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Findings';
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record instanceof Finding) {
|
|
||||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action supports acknowledging all matching findings.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page intentionally has no additional header actions.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Finding')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
|
||||||
TextEntry::make('severity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
|
||||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
|
|
||||||
TextEntry::make('scope_key')->label('Scope')->copyable(),
|
|
||||||
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
|
||||||
TextEntry::make('subject_type')->label('Subject type'),
|
|
||||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
|
||||||
TextEntry::make('baseline_operation_run_id')
|
|
||||||
->label('Baseline run')
|
|
||||||
->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
|
|
||||||
? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
|
|
||||||
: null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
TextEntry::make('current_operation_run_id')
|
|
||||||
->label('Current run')
|
|
||||||
->url(fn (Finding $record): ?string => $record->current_operation_run_id
|
|
||||||
? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
|
|
||||||
: null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
|
||||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Diff')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('settings_diff')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.normalized-diff')
|
|
||||||
->state(function (Finding $record): array {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant) {
|
|
||||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
||||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
||||||
|
|
||||||
$baselineVersion = is_numeric($baselineId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$currentVersion = is_numeric($currentId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
|
||||||
|
|
||||||
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
|
|
||||||
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
|
|
||||||
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
|
|
||||||
|
|
||||||
if (($addedCount + $removedCount + $changedCount) === 0) {
|
|
||||||
Arr::set(
|
|
||||||
$diff,
|
|
||||||
'summary.message',
|
|
||||||
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $diff;
|
|
||||||
})
|
|
||||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
ViewEntry::make('scope_tags_diff')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.scope-tags-diff')
|
|
||||||
->state(function (Finding $record): array {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant) {
|
|
||||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
||||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
||||||
|
|
||||||
$baselineVersion = is_numeric($baselineId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$currentVersion = is_numeric($currentId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
|
||||||
})
|
|
||||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
ViewEntry::make('assignments_diff')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.assignments-diff')
|
|
||||||
->state(function (Finding $record): array {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant) {
|
|
||||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
||||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
||||||
|
|
||||||
$baselineVersion = is_numeric($baselineId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$currentVersion = is_numeric($currentId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
|
||||||
})
|
|
||||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->collapsed()
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Evidence (Sanitized)')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('evidence_jsonb')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('severity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
|
||||||
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
Finding::STATUS_NEW => 'New',
|
|
||||||
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
|
|
||||||
])
|
|
||||||
->default(Finding::STATUS_NEW),
|
|
||||||
Tables\Filters\SelectFilter::make('finding_type')
|
|
||||||
->options([
|
|
||||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
|
||||||
])
|
|
||||||
->default(Finding::FINDING_TYPE_DRIFT),
|
|
||||||
Tables\Filters\Filter::make('scope_key')
|
|
||||||
->form([
|
|
||||||
TextInput::make('scope_key')
|
|
||||||
->label('Scope key')
|
|
||||||
->placeholder('Inventory selection hash')
|
|
||||||
->maxLength(255),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$scopeKey = $data['scope_key'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($scopeKey) || $scopeKey === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where('scope_key', $scopeKey);
|
|
||||||
}),
|
|
||||||
Tables\Filters\Filter::make('run_ids')
|
|
||||||
->label('Run IDs')
|
|
||||||
->form([
|
|
||||||
TextInput::make('baseline_operation_run_id')
|
|
||||||
->label('Baseline run id')
|
|
||||||
->numeric(),
|
|
||||||
TextInput::make('current_operation_run_id')
|
|
||||||
->label('Current run id')
|
|
||||||
->numeric(),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$baselineRunId = $data['baseline_operation_run_id'] ?? null;
|
|
||||||
if (is_numeric($baselineRunId)) {
|
|
||||||
$query->where('baseline_operation_run_id', (int) $baselineRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentRunId = $data['current_operation_run_id'] ?? null;
|
|
||||||
if (is_numeric($currentRunId)) {
|
|
||||||
$query->where('current_operation_run_id', (int) $currentRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('acknowledge')
|
|
||||||
->label('Acknowledge')
|
|
||||||
->icon('heroicon-o-check')
|
|
||||||
->color('gray')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
|
|
||||||
->action(function (Finding $record): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Finding belongs to a different tenant')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->acknowledge($user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Finding acknowledged')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('More')
|
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([
|
|
||||||
UiEnforcement::forBulkAction(
|
|
||||||
BulkAction::make('acknowledge_selected')
|
|
||||||
->label('Acknowledge selected')
|
|
||||||
->icon('heroicon-o-check')
|
|
||||||
->color('gray')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (Collection $records): void {
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$acknowledgedCount = 0;
|
|
||||||
$skippedCount = 0;
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
|
||||||
if (! $record instanceof Finding) {
|
|
||||||
$skippedCount++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
$skippedCount++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->status !== Finding::STATUS_NEW) {
|
|
||||||
$skippedCount++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->acknowledge($user);
|
|
||||||
$acknowledgedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
|
||||||
if ($skippedCount > 0) {
|
|
||||||
$body .= " Skipped {$skippedCount}.";
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk acknowledge completed')
|
|
||||||
->body($body)
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
])->label('More'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->addSelect([
|
|
||||||
'subject_display_name' => InventoryItem::query()
|
|
||||||
->select('display_name')
|
|
||||||
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
|
||||||
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
|
||||||
->limit(1),
|
|
||||||
])
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListFindings::route('/'),
|
|
||||||
'view' => Pages\ViewFinding::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class ListFindings extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('acknowledge_all_matching')
|
|
||||||
->label('Acknowledge all matching')
|
|
||||||
->icon('heroicon-o-check')
|
|
||||||
->color('gray')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
|
||||||
->modalDescription(function (): string {
|
|
||||||
$count = $this->getAllMatchingCount();
|
|
||||||
|
|
||||||
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
|
||||||
})
|
|
||||||
->form(function (): array {
|
|
||||||
$count = $this->getAllMatchingCount();
|
|
||||||
|
|
||||||
if ($count <= 100) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
TextInput::make('confirmation')
|
|
||||||
->label('Type ACKNOWLEDGE to confirm')
|
|
||||||
->required()
|
|
||||||
->in(['ACKNOWLEDGE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->action(function (array $data): void {
|
|
||||||
$query = $this->buildAllMatchingQuery();
|
|
||||||
$count = (clone $query)->count();
|
|
||||||
|
|
||||||
if ($count === 0) {
|
|
||||||
Notification::make()
|
|
||||||
->title('No matching findings')
|
|
||||||
->body('There are no new findings matching the current filters.')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $query->update([
|
|
||||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => auth()->id(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->deselectAllTableRecords();
|
|
||||||
$this->resetPage();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk acknowledge completed')
|
|
||||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildAllMatchingQuery(): Builder
|
|
||||||
{
|
|
||||||
$query = Finding::query();
|
|
||||||
|
|
||||||
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
|
||||||
|
|
||||||
if (! is_numeric($tenantId)) {
|
|
||||||
return $query->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->where('tenant_id', (int) $tenantId);
|
|
||||||
|
|
||||||
$query->where('status', Finding::STATUS_NEW);
|
|
||||||
|
|
||||||
$findingType = $this->getFindingTypeFilterValue();
|
|
||||||
if (is_string($findingType) && $findingType !== '') {
|
|
||||||
$query->where('finding_type', $findingType);
|
|
||||||
}
|
|
||||||
|
|
||||||
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
|
||||||
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
|
||||||
if (is_string($scopeKey) && $scopeKey !== '') {
|
|
||||||
$query->where('scope_key', $scopeKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
|
||||||
$baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id');
|
|
||||||
if (is_numeric($baselineRunId)) {
|
|
||||||
$query->where('baseline_operation_run_id', (int) $baselineRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentRunId = Arr::get($runIdsState, 'current_operation_run_id');
|
|
||||||
if (is_numeric($currentRunId)) {
|
|
||||||
$query->where('current_operation_run_id', (int) $currentRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getAllMatchingCount(): int
|
|
||||||
{
|
|
||||||
return (int) $this->buildAllMatchingQuery()->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getStatusFilterValue(): string
|
|
||||||
{
|
|
||||||
$state = $this->getTableFilterState('status') ?? [];
|
|
||||||
$value = Arr::get($state, 'value');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== ''
|
|
||||||
? $value
|
|
||||||
: Finding::STATUS_NEW;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getFindingTypeFilterValue(): string
|
|
||||||
{
|
|
||||||
$state = $this->getTableFilterState('finding_type') ?? [];
|
|
||||||
$value = Arr::get($state, 'value');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== ''
|
|
||||||
? $value
|
|
||||||
: Finding::FINDING_TYPE_DRIFT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewFinding extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingResource::class;
|
|
||||||
}
|
|
||||||
@ -1,482 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
|
||||||
use App\Filament\Support\VerificationReportViewer;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\VerificationCheckAcknowledgement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class OperationRunResource extends Resource
|
|
||||||
{
|
|
||||||
protected static bool $isScopedToTenant = false;
|
|
||||||
|
|
||||||
protected static ?string $model = OperationRun::class;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'operations';
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Operations';
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
|
||||||
moreGroupLabel: 'More',
|
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
|
||||||
))
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::ListHeader,
|
|
||||||
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
|
||||||
)
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::ListBulkMoreGroup,
|
|
||||||
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
|
||||||
)
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::ListEmptyState,
|
|
||||||
'Empty-state action is intentionally omitted; users can adjust filters/date range in-page.',
|
|
||||||
)
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::DetailHeader,
|
|
||||||
'Tenantless detail view is informational and currently has no header actions.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->with('user')
|
|
||||||
->latest('id')
|
|
||||||
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
|
|
||||||
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Run')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('type')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
TextEntry::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
TextEntry::make('initiator_name')->label('Initiator'),
|
|
||||||
TextEntry::make('target_scope_display')
|
|
||||||
->label('Target')
|
|
||||||
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
|
||||||
->columnSpanFull(),
|
|
||||||
TextEntry::make('target_scope_empty_state')
|
|
||||||
->label('Target')
|
|
||||||
->getStateUsing(static fn (): string => 'No target scope details were recorded for this run.')
|
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) === null)
|
|
||||||
->columnSpanFull(),
|
|
||||||
TextEntry::make('elapsed')
|
|
||||||
->label('Elapsed')
|
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
|
||||||
TextEntry::make('expected_duration')
|
|
||||||
->label('Expected')
|
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::expectedHuman($record) ?? '—'),
|
|
||||||
TextEntry::make('stuck_guidance')
|
|
||||||
->label('')
|
|
||||||
->getStateUsing(fn (OperationRun $record): ?string => RunDurationInsights::stuckGuidance($record))
|
|
||||||
->visible(fn (OperationRun $record): bool => RunDurationInsights::stuckGuidance($record) !== null),
|
|
||||||
TextEntry::make('created_at')->dateTime(),
|
|
||||||
TextEntry::make('started_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
|
|
||||||
])
|
|
||||||
->extraAttributes([
|
|
||||||
'x-init' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
|
||||||
'x-on:visibilitychange.window' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
|
||||||
])
|
|
||||||
->poll(function (OperationRun $record, $livewire): ?string {
|
|
||||||
if (($livewire->opsUxIsTabHidden ?? false) === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled($livewire->mountedActions ?? null)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return RunDetailPolling::interval($record);
|
|
||||||
})
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Counts')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('summary_counts')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Failures')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('failure_summary')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Verification report')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('verification_report')
|
|
||||||
->label('')
|
|
||||||
->view('filament.components.verification-report-viewer')
|
|
||||||
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
|
||||||
->viewData(function (OperationRun $record): array {
|
|
||||||
$report = VerificationReportViewer::report($record);
|
|
||||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
|
||||||
|
|
||||||
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
|
|
||||||
|
|
||||||
$previousRunUrl = null;
|
|
||||||
|
|
||||||
if ($changeIndicator !== null) {
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
$previousRunUrl = $tenant instanceof Tenant
|
|
||||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
|
||||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
|
||||||
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
|
||||||
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
|
||||||
->where('operation_run_id', (int) $record->getKey())
|
|
||||||
->with('acknowledgedByUser')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
|
||||||
$user = $ack->acknowledgedByUser;
|
|
||||||
|
|
||||||
return [
|
|
||||||
(string) $ack->check_key => [
|
|
||||||
'check_key' => (string) $ack->check_key,
|
|
||||||
'ack_reason' => (string) $ack->ack_reason,
|
|
||||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
|
||||||
'expires_at' => $ack->expires_at?->toJSON(),
|
|
||||||
'acknowledged_by' => $user instanceof User
|
|
||||||
? [
|
|
||||||
'id' => (int) $user->getKey(),
|
|
||||||
'name' => (string) $user->name,
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'run' => [
|
|
||||||
'id' => (int) $record->getKey(),
|
|
||||||
'type' => (string) $record->type,
|
|
||||||
'status' => (string) $record->status,
|
|
||||||
'outcome' => (string) $record->outcome,
|
|
||||||
'started_at' => $record->started_at?->toJSON(),
|
|
||||||
'completed_at' => $record->completed_at?->toJSON(),
|
|
||||||
],
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
'changeIndicator' => $changeIndicator,
|
|
||||||
'previousRunUrl' => $previousRunUrl,
|
|
||||||
'acknowledgements' => $acknowledgements,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Context')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('context')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(function (OperationRun $record): array {
|
|
||||||
$context = $record->context ?? [];
|
|
||||||
$context = is_array($context) ? $context : [];
|
|
||||||
|
|
||||||
if (array_key_exists('verification_report', $context)) {
|
|
||||||
$context['verification_report'] = [
|
|
||||||
'redacted' => true,
|
|
||||||
'note' => 'Rendered in the Verification report section.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $context;
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable()
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
|
||||||
->label('Started')
|
|
||||||
->since()
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('duration')
|
|
||||||
->getStateUsing(function (OperationRun $record): string {
|
|
||||||
if ($record->started_at && $record->completed_at) {
|
|
||||||
return $record->completed_at->diffForHumans($record->started_at, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '—';
|
|
||||||
}),
|
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(function (): array {
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->default(function (): ?string {
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $activeTenant->getKey();
|
|
||||||
})
|
|
||||||
->searchable(),
|
|
||||||
Tables\Filters\SelectFilter::make('type')
|
|
||||||
->options(function (): array {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->select('type')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('type')
|
|
||||||
->pluck('type', 'type')
|
|
||||||
->all();
|
|
||||||
}),
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
OperationRunStatus::Queued->value => 'Queued',
|
|
||||||
OperationRunStatus::Running->value => 'Running',
|
|
||||||
OperationRunStatus::Completed->value => 'Completed',
|
|
||||||
]),
|
|
||||||
Tables\Filters\SelectFilter::make('outcome')
|
|
||||||
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
|
||||||
Tables\Filters\SelectFilter::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->options(function (): array {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
|
|
||||||
? (int) $tenant->getKey()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
|
|
||||||
->whereNotNull('initiator_name')
|
|
||||||
->select('initiator_name')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('initiator_name')
|
|
||||||
->pluck('initiator_name', 'initiator_name')
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->searchable(),
|
|
||||||
Tables\Filters\Filter::make('created_at')
|
|
||||||
->label('Created')
|
|
||||||
->form([
|
|
||||||
DatePicker::make('created_from')
|
|
||||||
->label('From'),
|
|
||||||
DatePicker::make('created_until')
|
|
||||||
->label('Until'),
|
|
||||||
])
|
|
||||||
->default(fn (): array => [
|
|
||||||
'created_from' => now()->subDays(30),
|
|
||||||
'created_until' => now(),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$from = $data['created_from'] ?? null;
|
|
||||||
if ($from) {
|
|
||||||
$query->whereDate('created_at', '>=', $from);
|
|
||||||
}
|
|
||||||
|
|
||||||
$until = $data['created_until'] ?? null;
|
|
||||||
if ($until) {
|
|
||||||
$query->whereDate('created_at', '<=', $until);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\ViewAction::make()
|
|
||||||
->label('View run')
|
|
||||||
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
|
||||||
])
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
|
|
||||||
$targetScope = $context['target_scope'] ?? null;
|
|
||||||
if (! is_array($targetScope)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
|
||||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
|
||||||
|
|
||||||
$entraTenantName = is_string($entraTenantName) ? trim($entraTenantName) : null;
|
|
||||||
$entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : null;
|
|
||||||
|
|
||||||
$directoryContextId = match (true) {
|
|
||||||
is_string($directoryContextId) => trim($directoryContextId),
|
|
||||||
is_int($directoryContextId) => (string) $directoryContextId,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
$entra = null;
|
|
||||||
|
|
||||||
if ($entraTenantName !== null && $entraTenantName !== '') {
|
|
||||||
$entra = $entraTenantId ? "{$entraTenantName} ({$entraTenantId})" : $entraTenantName;
|
|
||||||
} elseif ($entraTenantId !== null && $entraTenantId !== '') {
|
|
||||||
$entra = $entraTenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = array_values(array_filter([
|
|
||||||
$entra,
|
|
||||||
$directoryContextId ? "directory_context_id: {$directoryContextId}" : null,
|
|
||||||
], fn (?string $value): bool => $value !== null && $value !== ''));
|
|
||||||
|
|
||||||
return $parts !== [] ? implode(' · ', $parts) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
|
||||||
use App\Jobs\SyncPoliciesJob;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListPolicies extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = PolicyResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [$this->makeSyncAction()];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
return [$this->makeSyncAction()];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeSyncAction(): Actions\Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('sync')
|
|
||||||
->label('Sync from Intune')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Sync policies from Intune')
|
|
||||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
|
||||||
->action(function (self $livewire): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestedTypes = array_map(
|
|
||||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
|
||||||
config('tenantpilot.supported_policy_types', [])
|
|
||||||
);
|
|
||||||
|
|
||||||
sort($requestedTypes);
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.sync',
|
|
||||||
inputs: [
|
|
||||||
'scope' => 'all',
|
|
||||||
'types' => $requestedTypes,
|
|
||||||
],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
|
||||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
|
|
||||||
});
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->tooltip('You do not have permission to sync policies.')
|
|
||||||
->apply();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Filament\Support\Enums\Width;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
|
|
||||||
class ViewPolicyVersion extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = PolicyVersionResource::class;
|
|
||||||
|
|
||||||
protected Width|string|null $maxContentWidth = Width::Full;
|
|
||||||
|
|
||||||
public function getFooter(): ?View
|
|
||||||
{
|
|
||||||
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
|
|
||||||
'record' => $this->getRecord(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,878 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Providers\CredentialManager;
|
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
|
||||||
use App\Services\Verification\StartVerification;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class EditProviderConnection extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = ProviderConnectionResource::class;
|
|
||||||
|
|
||||||
public ?string $scopedTenantExternalId = null;
|
|
||||||
|
|
||||||
protected bool $shouldMakeDefault = false;
|
|
||||||
|
|
||||||
protected bool $defaultWasChanged = false;
|
|
||||||
|
|
||||||
public function mount($record): void
|
|
||||||
{
|
|
||||||
parent::mount($record);
|
|
||||||
|
|
||||||
$recordTenant = $this->record instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::resolveTenantForRecord($this->record)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ($recordTenant instanceof Tenant) {
|
|
||||||
$this->scopedTenantExternalId = (string) $recordTenant->external_id;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIdFromQuery = request()->query('tenant_id');
|
|
||||||
|
|
||||||
if (is_string($tenantIdFromQuery) && $tenantIdFromQuery !== '') {
|
|
||||||
$this->scopedTenantExternalId = $tenantIdFromQuery;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = request()->route('tenant');
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$this->scopedTenantExternalId = (string) $tenant->external_id;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($tenant) && $tenant !== '') {
|
|
||||||
$this->scopedTenantExternalId = $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
|
||||||
{
|
|
||||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
|
||||||
unset($data['is_default']);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function afterSave(): void
|
|
||||||
{
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
$tenant = $record instanceof ProviderConnection
|
|
||||||
? ($record->tenant ?? $this->currentTenant())
|
|
||||||
: $this->currentTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
|
|
||||||
|
|
||||||
if ($this->shouldMakeDefault && ! $record->is_default) {
|
|
||||||
$record->makeDefault();
|
|
||||||
$this->defaultWasChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasDefault = $tenant->providerConnections()
|
|
||||||
->where('provider', $record->provider)
|
|
||||||
->where('is_default', true)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $hasDefault) {
|
|
||||||
$record->makeDefault();
|
|
||||||
$this->defaultWasChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
if ($changedFields !== []) {
|
|
||||||
app(AuditLogger::class)->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.updated',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'fields' => $changedFields,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->defaultWasChanged) {
|
|
||||||
app(AuditLogger::class)->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.default_set',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
|
|
||||||
return [
|
|
||||||
Actions\DeleteAction::make()
|
|
||||||
->visible(false),
|
|
||||||
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('view_last_check_run')
|
|
||||||
->label('View last check run')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
|
||||||
&& OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'provider.connection.check')
|
|
||||||
->where('context->provider_connection_id', (int) $record->getKey())
|
|
||||||
->exists())
|
|
||||||
->url(function (ProviderConnection $record): ?string {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'provider.connection.check')
|
|
||||||
->where('context->provider_connection_id', (int) $record->getKey())
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRunLinks::view($run, $tenant);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
|
||||||
->tooltip('You do not have permission to view provider connections.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('check_connection')
|
|
||||||
->label('Check connection')
|
|
||||||
->icon('heroicon-o-check-badge')
|
|
||||||
->color('success')
|
|
||||||
->visible(function (ProviderConnection $record): bool {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& $user instanceof User
|
|
||||||
&& $user->canAccessTenant($tenant)
|
|
||||||
&& $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $verification->providerConnectionCheck(
|
|
||||||
tenant: $tenant,
|
|
||||||
connection: $record,
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Scope busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
Action::make('manage_connections')
|
|
||||||
->label('Manage Provider Connections')
|
|
||||||
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('A connection check is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
Action::make('manage_connections')
|
|
||||||
->label('Manage Provider Connections')
|
|
||||||
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Connection check blocked')
|
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
Action::make('manage_connections')
|
|
||||||
->label('Manage Provider Connections')
|
|
||||||
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Connection check queued')
|
|
||||||
->body('Health check was queued and will run in the background.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('update_credentials')
|
|
||||||
->label('Update credentials')
|
|
||||||
->icon('heroicon-o-key')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
|
||||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
|
||||||
->form([
|
|
||||||
TextInput::make('client_id')
|
|
||||||
->label('Client ID')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('client_secret')
|
|
||||||
->label('Client secret')
|
|
||||||
->password()
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
])
|
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
|
||||||
connection: $record,
|
|
||||||
clientId: (string) $data['client_id'],
|
|
||||||
clientSecret: (string) $data['client_secret'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.credentials_updated',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'client_id' => (string) $data['client_id'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Credentials updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->tooltip('You do not have permission to manage provider connections.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('set_default')
|
|
||||||
->label('Set as default')
|
|
||||||
->icon('heroicon-o-star')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
|
||||||
&& $record->status !== 'disabled'
|
|
||||||
&& ! $record->is_default
|
|
||||||
&& ProviderConnection::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('provider', $record->provider)
|
|
||||||
->count() > 1)
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->makeDefault();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.default_set',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Default connection updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->tooltip('You do not have permission to manage provider connections.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('inventory_sync')
|
|
||||||
->label('Inventory sync')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('info')
|
|
||||||
->visible(function (ProviderConnection $record): bool {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& $user instanceof User
|
|
||||||
&& $user->canAccessTenant($tenant)
|
|
||||||
&& $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
|
||||||
tenant: $tenant,
|
|
||||||
connection: $record,
|
|
||||||
operationType: 'inventory_sync',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderInventorySyncJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Scope is busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->danger()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('An inventory sync is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Inventory sync blocked')
|
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Inventory sync queued')
|
|
||||||
->body('Inventory sync was queued and will run in the background.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->tooltip('You do not have permission to run provider operations.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('compliance_snapshot')
|
|
||||||
->label('Compliance snapshot')
|
|
||||||
->icon('heroicon-o-shield-check')
|
|
||||||
->color('info')
|
|
||||||
->visible(function (ProviderConnection $record): bool {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& $user instanceof User
|
|
||||||
&& $user->canAccessTenant($tenant)
|
|
||||||
&& $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
|
||||||
tenant: $tenant,
|
|
||||||
connection: $record,
|
|
||||||
operationType: 'compliance.snapshot',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderComplianceSnapshotJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Scope is busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->danger()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('A compliance snapshot is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Compliance snapshot blocked')
|
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Compliance snapshot queued')
|
|
||||||
->body('Compliance snapshot was queued and will run in the background.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->tooltip('You do not have permission to run provider operations.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('enable_connection')
|
|
||||||
->label('Enable connection')
|
|
||||||
->icon('heroicon-o-play')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
|
||||||
$previousStatus = (string) $record->status;
|
|
||||||
|
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
|
||||||
$errorReasonCode = null;
|
|
||||||
$errorMessage = null;
|
|
||||||
|
|
||||||
$record->update([
|
|
||||||
'status' => $status,
|
|
||||||
'health_status' => 'unknown',
|
|
||||||
'last_health_check_at' => null,
|
|
||||||
'last_error_reason_code' => $errorReasonCode,
|
|
||||||
'last_error_message' => $errorMessage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.enabled',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_status' => $previousStatus,
|
|
||||||
'to_status' => $status,
|
|
||||||
'credentials_present' => $hadCredentials,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $hadCredentials) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Connection enabled (needs consent)')
|
|
||||||
->body('Grant admin consent before running checks or operations.')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Provider connection enabled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->tooltip('You do not have permission to manage provider connections.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('disable_connection')
|
|
||||||
->label('Disable connection')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
|
||||||
|
|
||||||
$record->update([
|
|
||||||
'status' => 'disabled',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.disabled',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_status' => $previousStatus,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Provider connection disabled')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->tooltip('You do not have permission to manage provider connections.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('Actions')
|
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getFormActions(): array
|
|
||||||
{
|
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return [
|
|
||||||
$this->getCancelFormAction(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$capabilityResolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if ($capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
|
||||||
return parent::getFormActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
$this->getCancelFormAction(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
|
||||||
{
|
|
||||||
$tenant = $record instanceof ProviderConnection
|
|
||||||
? ($record->tenant ?? $this->currentTenant())
|
|
||||||
: $this->currentTenant();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$capabilityResolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $capabilityResolver->isMember($user, $tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::handleRecordUpdate($record, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
if (isset($this->record) && $this->record instanceof ProviderConnection) {
|
|
||||||
$recordTenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
|
||||||
|
|
||||||
if ($recordTenant instanceof Tenant) {
|
|
||||||
return $recordTenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('external_id', $this->scopedTenantExternalId)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = request()->route('tenant');
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($tenant) && $tenant !== '') {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('external_id', $tenant)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantFromCreateResolution = ProviderConnectionResource::resolveTenantForCreate();
|
|
||||||
|
|
||||||
if ($tenantFromCreateResolution instanceof Tenant) {
|
|
||||||
return $tenantFromCreateResolution;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Tenant::current();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewProviderConnection extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = ProviderConnectionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListRestoreRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$create = Actions\CreateAction::make();
|
|
||||||
UiEnforcement::forAction($create)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
return [
|
|
||||||
$create->visible(fn (): bool => $this->tableHasRecords()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
$create = Actions\CreateAction::make();
|
|
||||||
UiEnforcement::forAction($create)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
return [
|
|
||||||
$create,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewRestoreRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId !== null) {
|
|
||||||
$data['workspace_id'] = $workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function afterCreate(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$this->record->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('archive')
|
|
||||||
->label('Archive')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
|
||||||
->action(function (Tenant $record): void {
|
|
||||||
$record->delete();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->tooltip('You do not have permission to archive tenants.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTenants extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\CreateAction::make()
|
|
||||||
->disabled(fn (): bool => ! TenantResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.')
|
|
||||||
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\CreateAction::make()
|
|
||||||
->disabled(fn (): bool => ! TenantResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
|
||||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Verification\StartVerification;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewTenant extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
TenantArchivedBanner::class,
|
|
||||||
RecentOperationsSummary::class,
|
|
||||||
TenantVerificationReport::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('provider_connections')
|
|
||||||
->label('Provider connections')
|
|
||||||
->icon('heroicon-o-link')
|
|
||||||
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
Actions\Action::make('admin_consent')
|
|
||||||
->label('Admin consent')
|
|
||||||
->icon('heroicon-o-clipboard-document')
|
|
||||||
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
|
||||||
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
Actions\Action::make('open_in_entra')
|
|
||||||
->label('Open in Entra')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
|
||||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('verify')
|
|
||||||
->label('Verify configuration')
|
|
||||||
->icon('heroicon-o-check-badge')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
|
||||||
->action(function (
|
|
||||||
Tenant $record,
|
|
||||||
StartVerification $verification,
|
|
||||||
): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($record)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $verification->providerConnectionCheckForTenant(
|
|
||||||
tenant: $record,
|
|
||||||
initiator: $user,
|
|
||||||
extraContext: [
|
|
||||||
'surface' => [
|
|
||||||
'kind' => 'tenant_view_header',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Another operation is already running')
|
|
||||||
->body('Please wait for the active run to finish.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Verification already running')
|
|
||||||
->body('A verification run is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
$actions = [
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($runUrl),
|
|
||||||
];
|
|
||||||
|
|
||||||
$nextSteps = $result->run->context['next_steps'] ?? [];
|
|
||||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
|
||||||
|
|
||||||
foreach ($nextSteps as $index => $step) {
|
|
||||||
if (! is_array($step)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
|
||||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
|
||||||
|
|
||||||
if ($label === '' || $url === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Actions\Action::make('next_step_'.$index)
|
|
||||||
->label($label)
|
|
||||||
->url($url);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Verification blocked')
|
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
||||||
->warning()
|
|
||||||
->actions($actions)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Verification started')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->apply(),
|
|
||||||
TenantResource::rbacAction(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label('Deactivate')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record,
|
|
||||||
action: 'tenant.archived',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'internal_tenant_id' => (int) $record->getKey(),
|
|
||||||
'tenant_guid' => (string) $record->tenant_id,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant deactivated')
|
|
||||||
->body('The tenant has been archived and hidden from lists.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('Actions')
|
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Workspaces\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListWorkspaces extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = WorkspaceResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [$this->makeHeaderCreateAction()];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
return [$this->makeEmptyStateCreateAction()];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeHeaderCreateAction(): Actions\CreateAction
|
|
||||||
{
|
|
||||||
return $this->makeCreateAction()
|
|
||||||
->visible(fn (): bool => $this->tableHasRecords());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeEmptyStateCreateAction(): Actions\CreateAction
|
|
||||||
{
|
|
||||||
return $this->makeCreateAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeCreateAction(): Actions\CreateAction
|
|
||||||
{
|
|
||||||
return Actions\CreateAction::make()
|
|
||||||
->label('New workspace')
|
|
||||||
->disabled(fn (): bool => ! WorkspaceResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => WorkspaceResource::canCreate()
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to create workspaces.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Support;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Support\Verification\VerificationReportFingerprint;
|
|
||||||
use App\Support\Verification\VerificationReportSanitizer;
|
|
||||||
use App\Support\Verification\VerificationReportSchema;
|
|
||||||
|
|
||||||
final class VerificationReportViewer
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
public static function report(OperationRun $run): ?array
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
$report = $context['verification_report'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($report)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
|
||||||
|
|
||||||
if (! VerificationReportSchema::isValidReport($report)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $report;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function previousReportId(array $report): ?int
|
|
||||||
{
|
|
||||||
$previousReportId = $report['previous_report_id'] ?? null;
|
|
||||||
|
|
||||||
if (is_int($previousReportId) && $previousReportId > 0) {
|
|
||||||
return $previousReportId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) {
|
|
||||||
return (int) trim($previousReportId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fingerprint(array $report): ?string
|
|
||||||
{
|
|
||||||
$fingerprint = $report['fingerprint'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($fingerprint)) {
|
|
||||||
$fingerprint = strtolower(trim($fingerprint));
|
|
||||||
|
|
||||||
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
|
||||||
return $fingerprint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return VerificationReportFingerprint::forReport($report);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function previousRun(OperationRun $run, array $report): ?OperationRun
|
|
||||||
{
|
|
||||||
$previousReportId = self::previousReportId($report);
|
|
||||||
|
|
||||||
if ($previousReportId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = OperationRun::query()
|
|
||||||
->whereKey($previousReportId)
|
|
||||||
->where('tenant_id', (int) $run->tenant_id)
|
|
||||||
->where('workspace_id', (int) $run->workspace_id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $previous instanceof OperationRun ? $previous : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function shouldRenderForRun(OperationRun $run): bool
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
|
|
||||||
if (array_key_exists('verification_report', $context)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array((string) $run->type, ['provider.connection.check'], true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
|
|
||||||
class DashboardKpis extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected function getPollingInterval(): ?string
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
protected function getStats(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', 0),
|
|
||||||
Stat::make('High severity drift', 0),
|
|
||||||
Stat::make('Active operations', 0),
|
|
||||||
Stat::make('Inventory active', 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
$openDriftFindings = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$highSeverityDriftFindings = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$inventoryActiveRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', $openDriftFindings)
|
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
|
||||||
Stat::make('High severity drift', $highSeverityDriftFindings)
|
|
||||||
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray')
|
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
|
||||||
Stat::make('Active operations', $activeRuns)
|
|
||||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
|
||||||
->url(route('admin.operations.index')),
|
|
||||||
Stat::make('Inventory active', $inventoryActiveRuns)
|
|
||||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
|
||||||
->url(route('admin.operations.index')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Filament\Pages\DriftLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class NeedsAttention extends Widget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.dashboard.needs-attention';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'pollingInterval' => null,
|
|
||||||
'items' => [],
|
|
||||||
'healthyChecks' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
$highSeverityCount = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($highSeverityCount > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'High severity drift findings',
|
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestDriftSuccess = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'drift_generate_findings')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'succeeded')
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestDriftSuccess) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'No drift scan yet',
|
|
||||||
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
|
||||||
|
|
||||||
if ($isStale) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Drift stale',
|
|
||||||
'body' => 'Last drift scan is older than 7 days.',
|
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestDriftFailure = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'drift_generate_findings')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'failed')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestDriftFailure instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Drift generation failed',
|
|
||||||
'body' => 'Investigate the latest failed run.',
|
|
||||||
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Operations in progress',
|
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = array_slice($items, 0, 5);
|
|
||||||
|
|
||||||
$healthyChecks = [];
|
|
||||||
|
|
||||||
if ($items === []) {
|
|
||||||
$healthyChecks = [
|
|
||||||
[
|
|
||||||
'title' => 'Drift findings look healthy',
|
|
||||||
'body' => 'No high severity drift findings are open.',
|
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'linkLabel' => 'View findings',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Drift scans are up to date',
|
|
||||||
'body' => $latestDriftSuccess?->completed_at
|
|
||||||
? 'Last drift scan: '.$latestDriftSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
|
||||||
: 'Drift scan history is available in Drift.',
|
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
|
||||||
'linkLabel' => 'Open Drift',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'No active operations',
|
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'linkLabel' => 'View operations',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
|
||||||
'items' => $items,
|
|
||||||
'healthyChecks' => $healthyChecks,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Widgets\TableWidget;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class RecentOperations extends TableWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
return $table
|
|
||||||
->heading('Recent Operations')
|
|
||||||
->query($this->getQuery())
|
|
||||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
|
||||||
->paginated([10])
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('short_id')
|
|
||||||
->label('Run')
|
|
||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey())
|
|
||||||
->copyable()
|
|
||||||
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
|
|
||||||
TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->limit(40)
|
|
||||||
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
TextColumn::make('created_at')
|
|
||||||
->label('Started')
|
|
||||||
->since(),
|
|
||||||
])
|
|
||||||
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant
|
|
||||||
? OperationRunLinks::view($record, $tenant)
|
|
||||||
: null)
|
|
||||||
->emptyStateHeading('No operations yet')
|
|
||||||
->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<OperationRun>
|
|
||||||
*/
|
|
||||||
private function getQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->latest('created_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Inventory;
|
|
||||||
|
|
||||||
use App\Models\InventoryItem;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Inventory\InventoryKpiBadges;
|
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\HtmlString;
|
|
||||||
|
|
||||||
class InventoryKpiHeader extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inventory KPI aggregation source-of-truth:
|
|
||||||
* - `inventory_items.policy_type`
|
|
||||||
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
|
|
||||||
* - dependency capability via `CoverageCapabilitiesResolver`
|
|
||||||
*
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
protected function getStats(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
Stat::make('Total items', 0),
|
|
||||||
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'),
|
|
||||||
Stat::make('Last inventory sync', '—'),
|
|
||||||
Stat::make('Active ops', 0),
|
|
||||||
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
/** @var array<string, int> $countsByPolicyType */
|
|
||||||
$countsByPolicyType = InventoryItem::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->selectRaw('policy_type, COUNT(*) as aggregate')
|
|
||||||
->groupBy('policy_type')
|
|
||||||
->pluck('aggregate', 'policy_type')
|
|
||||||
->map(fn ($value): int => (int) $value)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$totalItems = array_sum($countsByPolicyType);
|
|
||||||
|
|
||||||
$restorableItems = 0;
|
|
||||||
$partialItems = 0;
|
|
||||||
$riskItems = 0;
|
|
||||||
|
|
||||||
foreach ($countsByPolicyType as $policyType => $count) {
|
|
||||||
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
|
|
||||||
$restorableItems += $count;
|
|
||||||
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
|
|
||||||
$partialItems += $count;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
|
|
||||||
$riskItems += $count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$coveragePercent = $totalItems > 0
|
|
||||||
? (int) round(($restorableItems / $totalItems) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
$lastRun = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$lastInventorySyncTimeLabel = '—';
|
|
||||||
$lastInventorySyncStatusLabel = '—';
|
|
||||||
$lastInventorySyncStatusColor = 'gray';
|
|
||||||
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
|
||||||
$lastInventorySyncViewUrl = null;
|
|
||||||
|
|
||||||
if ($lastRun instanceof OperationRun) {
|
|
||||||
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
|
|
||||||
|
|
||||||
if ($timestamp) {
|
|
||||||
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
|
|
||||||
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
|
||||||
$lastInventorySyncStatusLabel = $badge->label;
|
|
||||||
$lastInventorySyncStatusColor = $badge->color;
|
|
||||||
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
|
|
||||||
|
|
||||||
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$badgeColor = $lastInventorySyncStatusColor;
|
|
||||||
|
|
||||||
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<x-filament::badge :color="$badgeColor" size="sm">
|
|
||||||
{{ $statusLabel }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
@if ($viewUrl)
|
|
||||||
<x-filament::link :href="$viewUrl" size="sm">
|
|
||||||
View run
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'badgeColor' => $badgeColor,
|
|
||||||
'statusLabel' => $lastInventorySyncStatusLabel,
|
|
||||||
'viewUrl' => $lastInventorySyncViewUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$activeOps = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$inventoryOps = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
|
||||||
|
|
||||||
$dependenciesItems = 0;
|
|
||||||
foreach ($countsByPolicyType as $policyType => $count) {
|
|
||||||
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
|
||||||
$dependenciesItems += $count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Total items', $totalItems),
|
|
||||||
Stat::make('Coverage', $coveragePercent.'%')
|
|
||||||
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
|
|
||||||
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
|
|
||||||
->description(new HtmlString($lastInventorySyncDescription)),
|
|
||||||
Stat::make('Active ops', $activeOps),
|
|
||||||
Stat::make('Inventory ops', $inventoryOps)
|
|
||||||
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
final class ClearTenantContextController
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
app(WorkspaceContext::class)->clearLastTenantId($request);
|
|
||||||
|
|
||||||
$previousUrl = url()->previous();
|
|
||||||
|
|
||||||
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
|
|
||||||
|
|
||||||
if ($previousHost !== null && $previousHost !== $request->getHost()) {
|
|
||||||
return redirect()->to('/admin/operations');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to((string) $previousUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class TenantOnboardingController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$clientId = config('graph.client_id');
|
|
||||||
$redirectUri = route('admin.consent.callback');
|
|
||||||
$targetTenant = $request->string('tenant')->toString() ?: config('graph.tenant_id', 'organizations');
|
|
||||||
$tenantSegment = $targetTenant ?: 'organizations';
|
|
||||||
|
|
||||||
abort_if(empty($clientId) || empty($redirectUri), 500, 'Graph client not configured');
|
|
||||||
|
|
||||||
$state = Str::uuid()->toString();
|
|
||||||
$request->session()->put('tenant_onboard_state', $state);
|
|
||||||
|
|
||||||
$url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'scope' => 'https://graph.microsoft.com/.default',
|
|
||||||
'state' => $state,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->away($url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Response as HttpResponse;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class EnsureWorkspaceSelected
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*
|
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$routeName = $request->route()?->getName();
|
|
||||||
|
|
||||||
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = '/'.ltrim($request->path(), '/');
|
|
||||||
|
|
||||||
if ($this->isWorkspaceOptionalPath($request, $path)) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($path, '/admin/t/')) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var WorkspaceContext $context */
|
|
||||||
$context = app(WorkspaceContext::class);
|
|
||||||
|
|
||||||
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
|
|
||||||
|
|
||||||
if ($workspace !== null) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
|
||||||
|
|
||||||
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
|
||||||
? $membershipQuery
|
|
||||||
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
|
||||||
->whereNull('workspaces.archived_at')
|
|
||||||
->exists()
|
|
||||||
: $membershipQuery->exists();
|
|
||||||
|
|
||||||
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
|
||||||
|
|
||||||
if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/provider-connections')) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
|
|
||||||
? '/admin/choose-workspace'
|
|
||||||
: '/admin/no-access';
|
|
||||||
|
|
||||||
if ($target === '/admin/choose-workspace') {
|
|
||||||
WorkspaceIntendedUrl::storeFromRequest($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HttpResponse('', 302, ['Location' => $target]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
|
||||||
{
|
|
||||||
if (str_starts_with($path, '/admin/workspaces')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding'], true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($path === '/livewire/update') {
|
|
||||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
|
||||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
|
||||||
|
|
||||||
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isOperateHubPath(string $path): bool
|
|
||||||
{
|
|
||||||
return in_array($path, [
|
|
||||||
'/admin/operations',
|
|
||||||
'/admin/alerts',
|
|
||||||
'/admin/audit-log',
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Drift\DriftFindingGenerator;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class GenerateDriftFindingsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $tenantId,
|
|
||||||
public int $userId,
|
|
||||||
public int $baselineRunId,
|
|
||||||
public int $currentRunId,
|
|
||||||
public string $scopeKey,
|
|
||||||
?OperationRun $operationRun = null,
|
|
||||||
public array $context = [],
|
|
||||||
) {
|
|
||||||
$this->operationRun = $operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(
|
|
||||||
DriftFindingGenerator $generator,
|
|
||||||
OperationRunService $runs,
|
|
||||||
TargetScopeConcurrencyLimiter $limiter,
|
|
||||||
): void {
|
|
||||||
Log::info('GenerateDriftFindingsJob: started', [
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
'baseline_operation_run_id' => $this->baselineRunId,
|
|
||||||
'current_operation_run_id' => $this->currentRunId,
|
|
||||||
'scope_key' => $this->scopeKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
|
||||||
throw new RuntimeException('OperationRun is required for drift generation.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
|
|
||||||
if ($this->operationRun->status === 'completed') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$opContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
$targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : [];
|
|
||||||
|
|
||||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
|
||||||
|
|
||||||
if (! $lock) {
|
|
||||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
|
||||||
$this->release(max(1, $delay));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$tenant = Tenant::query()->find($this->tenantId);
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
throw new RuntimeException('Tenant not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$baseline = OperationRun::query()
|
|
||||||
->whereKey($this->baselineRunId)
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->first();
|
|
||||||
if (! $baseline instanceof OperationRun) {
|
|
||||||
throw new RuntimeException('Baseline run not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$current = OperationRun::query()
|
|
||||||
->whereKey($this->currentRunId)
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->first();
|
|
||||||
if (! $current instanceof OperationRun) {
|
|
||||||
throw new RuntimeException('Current run not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$runs->updateRun($this->operationRun, 'running');
|
|
||||||
|
|
||||||
$counts = is_array($this->operationRun->summary_counts ?? null) ? $this->operationRun->summary_counts : [];
|
|
||||||
if ((int) ($counts['total'] ?? 0) === 0) {
|
|
||||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$created = $generator->generate(
|
|
||||||
tenant: $tenant,
|
|
||||||
baseline: $baseline,
|
|
||||||
current: $current,
|
|
||||||
scopeKey: $this->scopeKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
Log::info('GenerateDriftFindingsJob: completed', [
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
'baseline_operation_run_id' => $this->baselineRunId,
|
|
||||||
'current_operation_run_id' => $this->currentRunId,
|
|
||||||
'scope_key' => $this->scopeKey,
|
|
||||||
'created_findings_count' => $created,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->incrementSummaryCounts($this->operationRun, [
|
|
||||||
'processed' => 1,
|
|
||||||
'succeeded' => 1,
|
|
||||||
'created' => $created,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('GenerateDriftFindingsJob: failed', [
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
'baseline_operation_run_id' => $this->baselineRunId,
|
|
||||||
'current_operation_run_id' => $this->currentRunId,
|
|
||||||
'scope_key' => $this->scopeKey,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->incrementSummaryCounts($this->operationRun, [
|
|
||||||
'processed' => 1,
|
|
||||||
'failed' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->appendFailures($this->operationRun, [[
|
|
||||||
'code' => 'drift_generate_findings.failed',
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]]);
|
|
||||||
|
|
||||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
} finally {
|
|
||||||
$lock->release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class PruneOldOperationRunsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $retentionDays = 90
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
OperationRun::where('created_at', '<', now()->subDays($this->retentionDays))
|
|
||||||
->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class Finding extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\FindingFactory> */
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const string FINDING_TYPE_DRIFT = 'drift';
|
|
||||||
|
|
||||||
public const string SEVERITY_LOW = 'low';
|
|
||||||
|
|
||||||
public const string SEVERITY_MEDIUM = 'medium';
|
|
||||||
|
|
||||||
public const string SEVERITY_HIGH = 'high';
|
|
||||||
|
|
||||||
public const string STATUS_NEW = 'new';
|
|
||||||
|
|
||||||
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'acknowledged_at' => 'datetime',
|
|
||||||
'evidence_jsonb' => 'array',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function baselineRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(OperationRun::class, 'baseline_operation_run_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(OperationRun::class, 'current_operation_run_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function acknowledgedByUser(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function acknowledge(User $user): void
|
|
||||||
{
|
|
||||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->forceFill([
|
|
||||||
'status' => self::STATUS_ACKNOWLEDGED,
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class OperationRun extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'summary_counts' => 'array',
|
|
||||||
'failure_summary' => 'array',
|
|
||||||
'context' => 'array',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'completed_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected static function booted(): void
|
|
||||||
{
|
|
||||||
static::creating(function (self $operationRun): void {
|
|
||||||
if ($operationRun->workspace_id !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operationRun->tenant_id === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()->whereKey((int) $operationRun->tenant_id)->first();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant->workspace_id === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRun->workspace_id = (int) $tenant->workspace_id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeActive(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereIn('status', ['queued', 'running']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSelectionHashAttribute(): ?string
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
|
|
||||||
return isset($context['selection_hash']) && is_string($context['selection_hash'])
|
|
||||||
? $context['selection_hash']
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSelectionHashAttribute(?string $value): void
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
$context['selection_hash'] = $value;
|
|
||||||
|
|
||||||
$this->context = $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function getSelectionPayloadAttribute(): array
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
|
|
||||||
return Arr::only($context, [
|
|
||||||
'policy_types',
|
|
||||||
'categories',
|
|
||||||
'include_foundations',
|
|
||||||
'include_dependencies',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $value
|
|
||||||
*/
|
|
||||||
public function setSelectionPayloadAttribute(?array $value): void
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
$context = array_merge($context, Arr::only($value, [
|
|
||||||
'policy_types',
|
|
||||||
'categories',
|
|
||||||
'include_foundations',
|
|
||||||
'include_dependencies',
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->context = $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFinishedAtAttribute(): mixed
|
|
||||||
{
|
|
||||||
return $this->completed_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setFinishedAtAttribute(mixed $value): void
|
|
||||||
{
|
|
||||||
$this->completed_at = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
class PolicyVersion extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
use SoftDeletes;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'snapshot' => 'array',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'assignments' => 'array',
|
|
||||||
'scope_tags' => 'array',
|
|
||||||
'captured_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function previous(): ?self
|
|
||||||
{
|
|
||||||
return $this->policy
|
|
||||||
? $this->policy
|
|
||||||
->versions()
|
|
||||||
->where('version_number', '<', $this->version_number)
|
|
||||||
->orderByDesc('version_number')
|
|
||||||
->first()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function policy(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Policy::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopePruneEligible($query, int $days = 90)
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('captured_at', '<', now()->subDays($days))
|
|
||||||
->whereRaw(
|
|
||||||
'policy_versions.version_number < (select max(pv2.version_number) from policy_versions pv2 where pv2.policy_id = policy_versions.policy_id and pv2.deleted_at is null)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ProviderConnection extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'is_default' => 'boolean',
|
|
||||||
'scopes_granted' => 'array',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'last_health_check_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function credential(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function makeDefault(): void
|
|
||||||
{
|
|
||||||
DB::transaction(function (): void {
|
|
||||||
static::query()
|
|
||||||
->where('tenant_id', $this->tenant_id)
|
|
||||||
->where('provider', $this->provider)
|
|
||||||
->where('is_default', true)
|
|
||||||
->whereKeyNot($this->getKey())
|
|
||||||
->update(['is_default' => false]);
|
|
||||||
|
|
||||||
static::query()
|
|
||||||
->whereKey($this->getKey())
|
|
||||||
->update(['is_default' => true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class TenantOnboardingSession extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'managed_tenant_onboarding_sessions';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
public const STATE_ALLOWED_KEYS = [
|
|
||||||
'entra_tenant_id',
|
|
||||||
'tenant_id',
|
|
||||||
'tenant_name',
|
|
||||||
'environment',
|
|
||||||
'primary_domain',
|
|
||||||
'notes',
|
|
||||||
'provider_connection_id',
|
|
||||||
'selected_provider_connection_id',
|
|
||||||
'verification_operation_run_id',
|
|
||||||
'verification_run_id',
|
|
||||||
'bootstrap_operation_types',
|
|
||||||
'bootstrap_operation_runs',
|
|
||||||
'bootstrap_run_ids',
|
|
||||||
'connection_recently_updated',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'state' => 'array',
|
|
||||||
'completed_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $value
|
|
||||||
*/
|
|
||||||
public function setStateAttribute(?array $value): void
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
$this->attributes['state'] = null;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowed = array_intersect_key($value, array_flip(self::STATE_ALLOWED_KEYS));
|
|
||||||
|
|
||||||
$encoded = json_encode($allowed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
|
|
||||||
$this->attributes['state'] = $encoded !== false ? $encoded : json_encode([], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function startedByUser(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'started_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function updatedByUser(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'updated_by_user_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Notifications;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
class OperationRunCompleted extends Notification
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public OperationRun $run
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
return OperationUxPresenter::terminalDatabaseNotification(
|
|
||||||
run: $this->run,
|
|
||||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
|
||||||
)->getDatabaseMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Notifications;
|
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
class RunStatusChangedNotification extends Notification
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* tenant_id:int,
|
|
||||||
* run_type:string,
|
|
||||||
* run_id:int,
|
|
||||||
* status:string,
|
|
||||||
* counts?:array{total?:int, processed?:int, succeeded?:int, failed?:int, skipped?:int}
|
|
||||||
* } $metadata
|
|
||||||
*/
|
|
||||||
public function __construct(public array $metadata) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$status = (string) ($this->metadata['status'] ?? 'queued');
|
|
||||||
$runType = (string) ($this->metadata['run_type'] ?? 'run');
|
|
||||||
$tenantId = (int) ($this->metadata['tenant_id'] ?? 0);
|
|
||||||
$runId = (int) ($this->metadata['run_id'] ?? 0);
|
|
||||||
|
|
||||||
$title = match ($status) {
|
|
||||||
'queued' => 'Run queued',
|
|
||||||
'running' => 'Run started',
|
|
||||||
'completed', 'succeeded' => 'Run completed',
|
|
||||||
'partial', 'partially succeeded', 'completed_with_errors' => 'Run completed (partial)',
|
|
||||||
'failed' => 'Run failed',
|
|
||||||
default => 'Run updated',
|
|
||||||
};
|
|
||||||
|
|
||||||
$body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status);
|
|
||||||
|
|
||||||
$color = match ($status) {
|
|
||||||
'queued', 'running' => 'gray',
|
|
||||||
'completed', 'succeeded' => 'success',
|
|
||||||
'partial', 'partially succeeded', 'completed_with_errors' => 'warning',
|
|
||||||
'failed' => 'danger',
|
|
||||||
default => 'gray',
|
|
||||||
};
|
|
||||||
|
|
||||||
$actions = [];
|
|
||||||
|
|
||||||
if ($tenantId > 0 && $runId > 0) {
|
|
||||||
$tenant = Tenant::query()->find($tenantId);
|
|
||||||
|
|
||||||
if ($tenant) {
|
|
||||||
$url = $runType === 'restore'
|
|
||||||
? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
|
|
||||||
: OperationRunLinks::view($runId, $tenant);
|
|
||||||
|
|
||||||
if (! $url) {
|
|
||||||
return [
|
|
||||||
'format' => 'filament',
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $body,
|
|
||||||
'color' => $color,
|
|
||||||
'duration' => 'persistent',
|
|
||||||
'actions' => [],
|
|
||||||
'icon' => null,
|
|
||||||
'iconColor' => null,
|
|
||||||
'status' => null,
|
|
||||||
'view' => null,
|
|
||||||
'viewData' => [
|
|
||||||
'metadata' => $this->metadata,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($url)
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'format' => 'filament',
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $body,
|
|
||||||
'color' => $color,
|
|
||||||
'duration' => 'persistent',
|
|
||||||
'actions' => $actions,
|
|
||||||
'icon' => null,
|
|
||||||
'iconColor' => null,
|
|
||||||
'status' => null,
|
|
||||||
'view' => null,
|
|
||||||
'viewData' => [
|
|
||||||
'metadata' => $this->metadata,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\BackupSchedule;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class BackupSchedulePolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
|
|
||||||
{
|
|
||||||
$tenant ??= Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
return $this->isTenantMember($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $this->isTenantMember($user, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $schedule->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function restore(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forceDelete(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\EntraGroup;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class EntraGroupPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, EntraGroup $group): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $group->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class FindingPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, Finding $finding): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $finding->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, Finding $finding): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Audit;
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Audit\AuditContextSanitizer;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
|
|
||||||
class WorkspaceAuditLogger
|
|
||||||
{
|
|
||||||
public function log(
|
|
||||||
Workspace $workspace,
|
|
||||||
string $action,
|
|
||||||
array $context = [],
|
|
||||||
?User $actor = null,
|
|
||||||
string $status = 'success',
|
|
||||||
?string $resourceType = null,
|
|
||||||
?string $resourceId = null,
|
|
||||||
?int $actorId = null,
|
|
||||||
?string $actorEmail = null,
|
|
||||||
?string $actorName = null,
|
|
||||||
): AuditLog {
|
|
||||||
$metadata = $context['metadata'] ?? [];
|
|
||||||
unset($context['metadata']);
|
|
||||||
|
|
||||||
$metadata = is_array($metadata) ? $metadata : [];
|
|
||||||
|
|
||||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
|
||||||
|
|
||||||
return AuditLog::create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'actor_id' => $actor?->getKey() ?? $actorId,
|
|
||||||
'actor_email' => $actor?->email ?? $actorEmail,
|
|
||||||
'actor_name' => $actor?->name ?? $actorName,
|
|
||||||
'action' => $action,
|
|
||||||
'resource_type' => $resourceType,
|
|
||||||
'resource_id' => $resourceId,
|
|
||||||
'status' => $status,
|
|
||||||
'metadata' => $sanitizedMetadata,
|
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,305 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Drift;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Policy;
|
|
||||||
use App\Models\PolicyVersion;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
|
||||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class DriftFindingGenerator
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly DriftHasher $hasher,
|
|
||||||
private readonly DriftEvidence $evidence,
|
|
||||||
private readonly SettingsNormalizer $settingsNormalizer,
|
|
||||||
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int
|
|
||||||
{
|
|
||||||
if (! $baseline->completed_at || ! $current->completed_at) {
|
|
||||||
throw new RuntimeException('Baseline/current run must be finished.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, mixed> $selection */
|
|
||||||
$selection = is_array($current->context) ? $current->context : [];
|
|
||||||
|
|
||||||
$policyTypes = Arr::get($selection, 'policy_types');
|
|
||||||
if (! is_array($policyTypes)) {
|
|
||||||
$policyTypes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
|
|
||||||
|
|
||||||
$created = 0;
|
|
||||||
|
|
||||||
Policy::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->whereIn('policy_type', $policyTypes)
|
|
||||||
->orderBy('id')
|
|
||||||
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, &$created): void {
|
|
||||||
foreach ($policies as $policy) {
|
|
||||||
if (! $policy instanceof Policy) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineVersion = $this->versionForRun($policy, $baseline);
|
|
||||||
$currentVersion = $this->versionForRun($policy, $current);
|
|
||||||
|
|
||||||
if ($baselineVersion instanceof PolicyVersion || $currentVersion instanceof PolicyVersion) {
|
|
||||||
$policyType = (string) ($policy->policy_type ?? '');
|
|
||||||
$platform = is_string($policy->platform ?? null) ? $policy->platform : null;
|
|
||||||
|
|
||||||
$baselineSnapshot = $baselineVersion instanceof PolicyVersion && is_array($baselineVersion->snapshot)
|
|
||||||
? $baselineVersion->snapshot
|
|
||||||
: [];
|
|
||||||
$currentSnapshot = $currentVersion instanceof PolicyVersion && is_array($currentVersion->snapshot)
|
|
||||||
? $currentVersion->snapshot
|
|
||||||
: [];
|
|
||||||
|
|
||||||
$baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform);
|
|
||||||
$currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform);
|
|
||||||
|
|
||||||
$baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized);
|
|
||||||
$currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized);
|
|
||||||
|
|
||||||
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
|
||||||
$changeType = match (true) {
|
|
||||||
$baselineVersion instanceof PolicyVersion && ! $currentVersion instanceof PolicyVersion => 'removed',
|
|
||||||
! $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion => 'added',
|
|
||||||
default => 'modified',
|
|
||||||
};
|
|
||||||
|
|
||||||
$fingerprint = $this->hasher->fingerprint(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
scopeKey: $scopeKey,
|
|
||||||
subjectType: 'policy',
|
|
||||||
subjectExternalId: (string) $policy->external_id,
|
|
||||||
changeType: $changeType,
|
|
||||||
baselineHash: $baselineSnapshotHash,
|
|
||||||
currentHash: $currentSnapshotHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
$rawEvidence = [
|
|
||||||
'change_type' => $changeType,
|
|
||||||
'summary' => [
|
|
||||||
'kind' => 'policy_snapshot',
|
|
||||||
'changed_fields' => ['snapshot_hash'],
|
|
||||||
],
|
|
||||||
'baseline' => [
|
|
||||||
'policy_id' => $policy->external_id,
|
|
||||||
'policy_version_id' => $baselineVersion?->getKey(),
|
|
||||||
'snapshot_hash' => $baselineSnapshotHash,
|
|
||||||
],
|
|
||||||
'current' => [
|
|
||||||
'policy_id' => $policy->external_id,
|
|
||||||
'policy_version_id' => $currentVersion?->getKey(),
|
|
||||||
'snapshot_hash' => $currentSnapshotHash,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$finding = Finding::query()->firstOrNew([
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$wasNew = ! $finding->exists;
|
|
||||||
|
|
||||||
$finding->forceFill([
|
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
||||||
'scope_key' => $scopeKey,
|
|
||||||
'baseline_operation_run_id' => $baseline->getKey(),
|
|
||||||
'current_operation_run_id' => $current->getKey(),
|
|
||||||
'subject_type' => 'policy',
|
|
||||||
'subject_external_id' => (string) $policy->external_id,
|
|
||||||
'severity' => Finding::SEVERITY_MEDIUM,
|
|
||||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($wasNew) {
|
|
||||||
$finding->forceFill([
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'acknowledged_at' => null,
|
|
||||||
'acknowledged_by_user_id' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$finding->save();
|
|
||||||
|
|
||||||
if ($wasNew) {
|
|
||||||
$created++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
|
||||||
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
|
||||||
|
|
||||||
$baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments);
|
|
||||||
$currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments);
|
|
||||||
|
|
||||||
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
|
||||||
$fingerprint = $this->hasher->fingerprint(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
scopeKey: $scopeKey,
|
|
||||||
subjectType: 'assignment',
|
|
||||||
subjectExternalId: (string) $policy->external_id,
|
|
||||||
changeType: 'modified',
|
|
||||||
baselineHash: (string) ($baselineAssignmentsHash ?? ''),
|
|
||||||
currentHash: (string) ($currentAssignmentsHash ?? ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
$rawEvidence = [
|
|
||||||
'change_type' => 'modified',
|
|
||||||
'summary' => [
|
|
||||||
'kind' => 'policy_assignments',
|
|
||||||
'changed_fields' => ['assignments_hash'],
|
|
||||||
],
|
|
||||||
'baseline' => [
|
|
||||||
'policy_id' => $policy->external_id,
|
|
||||||
'policy_version_id' => $baselineVersion->getKey(),
|
|
||||||
'assignments_hash' => $baselineAssignmentsHash,
|
|
||||||
],
|
|
||||||
'current' => [
|
|
||||||
'policy_id' => $policy->external_id,
|
|
||||||
'policy_version_id' => $currentVersion->getKey(),
|
|
||||||
'assignments_hash' => $currentAssignmentsHash,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$finding = Finding::query()->firstOrNew([
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$wasNew = ! $finding->exists;
|
|
||||||
|
|
||||||
$finding->forceFill([
|
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
||||||
'scope_key' => $scopeKey,
|
|
||||||
'baseline_operation_run_id' => $baseline->getKey(),
|
|
||||||
'current_operation_run_id' => $current->getKey(),
|
|
||||||
'subject_type' => 'assignment',
|
|
||||||
'subject_external_id' => (string) $policy->external_id,
|
|
||||||
'severity' => Finding::SEVERITY_MEDIUM,
|
|
||||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($wasNew) {
|
|
||||||
$finding->forceFill([
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'acknowledged_at' => null,
|
|
||||||
'acknowledged_by_user_id' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$finding->save();
|
|
||||||
|
|
||||||
if ($wasNew) {
|
|
||||||
$created++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
|
||||||
$currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
|
||||||
|
|
||||||
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineScopeTagsHash = $this->hasher->hashNormalized($baselineScopeTagIds);
|
|
||||||
$currentScopeTagsHash = $this->hasher->hashNormalized($currentScopeTagIds);
|
|
||||||
|
|
||||||
if ($baselineScopeTagsHash === $currentScopeTagsHash) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fingerprint = $this->hasher->fingerprint(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
scopeKey: $scopeKey,
|
|
||||||
subjectType: 'scope_tag',
|
|
||||||
subjectExternalId: (string) $policy->external_id,
|
|
||||||
changeType: 'modified',
|
|
||||||
baselineHash: $baselineScopeTagsHash,
|
|
||||||
currentHash: $currentScopeTagsHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
$rawEvidence = [
|
|
||||||
'change_type' => 'modified',
|
|
||||||
'summary' => [
|
|
||||||
'kind' => 'policy_scope_tags',
|
|
||||||
'changed_fields' => ['scope_tags_hash'],
|
|
||||||
],
|
|
||||||
'baseline' => [
|
|
||||||
'policy_id' => $policy->external_id,
|
|
||||||
'policy_version_id' => $baselineVersion->getKey(),
|
|
||||||
'scope_tags_hash' => $baselineScopeTagsHash,
|
|
||||||
],
|
|
||||||
'current' => [
|
|
||||||
'policy_id' => $policy->external_id,
|
|
||||||
'policy_version_id' => $currentVersion->getKey(),
|
|
||||||
'scope_tags_hash' => $currentScopeTagsHash,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$finding = Finding::query()->firstOrNew([
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$wasNew = ! $finding->exists;
|
|
||||||
|
|
||||||
$finding->forceFill([
|
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
||||||
'scope_key' => $scopeKey,
|
|
||||||
'baseline_operation_run_id' => $baseline->getKey(),
|
|
||||||
'current_operation_run_id' => $current->getKey(),
|
|
||||||
'subject_type' => 'scope_tag',
|
|
||||||
'subject_external_id' => (string) $policy->external_id,
|
|
||||||
'severity' => Finding::SEVERITY_MEDIUM,
|
|
||||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($wasNew) {
|
|
||||||
$finding->forceFill([
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'acknowledged_at' => null,
|
|
||||||
'acknowledged_by_user_id' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$finding->save();
|
|
||||||
|
|
||||||
if ($wasNew) {
|
|
||||||
$created++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $created;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion
|
|
||||||
{
|
|
||||||
if (! $run->completed_at) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PolicyVersion::query()
|
|
||||||
->where('tenant_id', $policy->tenant_id)
|
|
||||||
->where('policy_id', $policy->getKey())
|
|
||||||
->where('captured_at', '<=', $run->completed_at)
|
|
||||||
->latest('captured_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Drift;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
|
|
||||||
class DriftRunSelector
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array{baseline:OperationRun,current:OperationRun}|null
|
|
||||||
*/
|
|
||||||
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
|
|
||||||
{
|
|
||||||
$runs = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->whereIn('outcome', [
|
|
||||||
OperationRunOutcome::Succeeded->value,
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
])
|
|
||||||
->where('context->selection_hash', $scopeKey)
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->limit(2)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($runs->count() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$current = $runs->first();
|
|
||||||
$baseline = $runs->last();
|
|
||||||
|
|
||||||
if (! $baseline instanceof OperationRun || ! $current instanceof OperationRun) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'baseline' => $baseline,
|
|
||||||
'current' => $current,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Drift;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
|
|
||||||
class DriftScopeKey
|
|
||||||
{
|
|
||||||
public function fromRun(OperationRun $run): string
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
|
|
||||||
return (string) ($context['selection_hash'] ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Intune;
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Audit\AuditContextSanitizer;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class AuditLogger
|
|
||||||
{
|
|
||||||
public function log(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $action,
|
|
||||||
array $context = [],
|
|
||||||
?int $actorId = null,
|
|
||||||
?string $actorEmail = null,
|
|
||||||
?string $actorName = null,
|
|
||||||
string $status = 'success',
|
|
||||||
?string $resourceType = null,
|
|
||||||
?string $resourceId = null,
|
|
||||||
): AuditLog {
|
|
||||||
$metadata = $context['metadata'] ?? [];
|
|
||||||
unset($context['metadata']);
|
|
||||||
|
|
||||||
$metadata = is_array($metadata) ? $metadata : [];
|
|
||||||
|
|
||||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
|
||||||
$workspaceId = is_numeric($tenant->workspace_id) ? (int) $tenant->workspace_id : null;
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
throw new InvalidArgumentException('Tenant-scoped audit events require tenant workspace_id.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return AuditLog::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'actor_id' => $actorId,
|
|
||||||
'actor_email' => $actorEmail,
|
|
||||||
'actor_name' => $actorName,
|
|
||||||
'action' => $action,
|
|
||||||
'resource_type' => $resourceType,
|
|
||||||
'resource_id' => $resourceId,
|
|
||||||
'status' => $status,
|
|
||||||
'metadata' => $sanitizedMetadata,
|
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Audit;
|
|
||||||
|
|
||||||
enum AuditActionId: string
|
|
||||||
{
|
|
||||||
case WorkspaceMembershipAdd = 'workspace_membership.add';
|
|
||||||
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
|
|
||||||
case WorkspaceMembershipRemove = 'workspace_membership.remove';
|
|
||||||
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
|
|
||||||
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
|
|
||||||
|
|
||||||
case TenantMembershipAdd = 'tenant_membership.add';
|
|
||||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
|
||||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
|
||||||
case TenantMembershipLastOwnerBlocked = 'tenant_membership.last_owner_blocked';
|
|
||||||
|
|
||||||
// Not part of the v1 contract, but used in codebase.
|
|
||||||
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
|
|
||||||
|
|
||||||
// Diagnostics / repair actions.
|
|
||||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
|
||||||
|
|
||||||
// Managed tenant onboarding wizard.
|
|
||||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
|
||||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
|
||||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
|
||||||
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
|
|
||||||
case VerificationCompleted = 'verification.completed';
|
|
||||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Audit;
|
|
||||||
|
|
||||||
final class AuditContextSanitizer
|
|
||||||
{
|
|
||||||
private const REDACTED = '[REDACTED]';
|
|
||||||
|
|
||||||
public static function sanitize(mixed $value): mixed
|
|
||||||
{
|
|
||||||
if (is_array($value)) {
|
|
||||||
$sanitized = [];
|
|
||||||
|
|
||||||
foreach ($value as $key => $item) {
|
|
||||||
if (is_string($key) && self::shouldRedactKey($key)) {
|
|
||||||
$sanitized[$key] = self::REDACTED;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sanitized[$key] = self::sanitize($item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($value)) {
|
|
||||||
return self::sanitizeString($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function shouldRedactKey(string $key): bool
|
|
||||||
{
|
|
||||||
$key = strtolower(trim($key));
|
|
||||||
|
|
||||||
return str_contains($key, 'token')
|
|
||||||
|| str_contains($key, 'secret')
|
|
||||||
|| str_contains($key, 'password')
|
|
||||||
|| str_contains($key, 'authorization')
|
|
||||||
|| str_contains($key, 'private_key')
|
|
||||||
|| str_contains($key, 'client_secret');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function sanitizeString(string $value): string
|
|
||||||
{
|
|
||||||
$candidate = trim($value);
|
|
||||||
|
|
||||||
if ($candidate === '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
|
|
||||||
return self::REDACTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
|
|
||||||
return self::REDACTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges;
|
|
||||||
|
|
||||||
use BackedEnum;
|
|
||||||
use Stringable;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
final class BadgeCatalog
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string, class-string<BadgeMapper>>
|
|
||||||
*/
|
|
||||||
private const DOMAIN_MAPPERS = [
|
|
||||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
|
||||||
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
|
|
||||||
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
|
|
||||||
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
|
|
||||||
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
|
|
||||||
BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class,
|
|
||||||
BadgeDomain::FindingSeverity->value => Domains\FindingSeverityBadge::class,
|
|
||||||
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
|
||||||
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
|
||||||
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
|
||||||
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
|
|
||||||
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
|
||||||
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
|
||||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
|
||||||
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
|
|
||||||
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
|
|
||||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
|
||||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
|
||||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
|
||||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
|
||||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
|
||||||
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
|
|
||||||
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
|
||||||
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
|
||||||
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, BadgeMapper|null>
|
|
||||||
*/
|
|
||||||
private static array $mapperCache = [];
|
|
||||||
|
|
||||||
public static function spec(BadgeDomain $domain, mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$mapper = self::mapper($domain);
|
|
||||||
|
|
||||||
if (! $mapper) {
|
|
||||||
return BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $mapper->spec($value);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function mapper(BadgeDomain $domain): ?BadgeMapper
|
|
||||||
{
|
|
||||||
$key = $domain->value;
|
|
||||||
|
|
||||||
if (array_key_exists($key, self::$mapperCache)) {
|
|
||||||
return self::$mapperCache[$key];
|
|
||||||
}
|
|
||||||
|
|
||||||
$mapper = self::buildMapper($domain);
|
|
||||||
|
|
||||||
self::$mapperCache[$key] = $mapper;
|
|
||||||
|
|
||||||
return $mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function normalizeState(mixed $value): ?string
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value instanceof BackedEnum) {
|
|
||||||
$value = $value->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value instanceof Stringable) {
|
|
||||||
$value = (string) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_bool($value)) {
|
|
||||||
return $value ? 'true' : 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_int($value) || is_float($value)) {
|
|
||||||
return (string) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = strtolower(trim($value));
|
|
||||||
$normalized = str_replace([' ', '-'], '_', $normalized);
|
|
||||||
|
|
||||||
return $normalized === '' ? null : $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function buildMapper(BadgeDomain $domain): ?BadgeMapper
|
|
||||||
{
|
|
||||||
$mapperClass = self::DOMAIN_MAPPERS[$domain->value] ?? null;
|
|
||||||
|
|
||||||
if (! $mapperClass) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! class_exists($mapperClass)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mapper = new $mapperClass;
|
|
||||||
|
|
||||||
return $mapper instanceof BadgeMapper ? $mapper : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges;
|
|
||||||
|
|
||||||
enum BadgeDomain: string
|
|
||||||
{
|
|
||||||
case OperationRunStatus = 'operation_run_status';
|
|
||||||
case OperationRunOutcome = 'operation_run_outcome';
|
|
||||||
case BackupSetStatus = 'backup_set_status';
|
|
||||||
case RestoreRunStatus = 'restore_run_status';
|
|
||||||
case RestoreCheckSeverity = 'restore_check_severity';
|
|
||||||
case FindingStatus = 'finding_status';
|
|
||||||
case FindingSeverity = 'finding_severity';
|
|
||||||
case BooleanEnabled = 'boolean_enabled';
|
|
||||||
case BooleanHasErrors = 'boolean_has_errors';
|
|
||||||
case TenantStatus = 'tenant_status';
|
|
||||||
case TenantAppStatus = 'tenant_app_status';
|
|
||||||
case TenantRbacStatus = 'tenant_rbac_status';
|
|
||||||
case TenantPermissionStatus = 'tenant_permission_status';
|
|
||||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
|
||||||
case PolicyRestoreMode = 'policy_restore_mode';
|
|
||||||
case PolicyRisk = 'policy_risk';
|
|
||||||
case IgnoredAt = 'ignored_at';
|
|
||||||
case RestorePreviewDecision = 'restore_preview_decision';
|
|
||||||
case RestoreResultStatus = 'restore_result_status';
|
|
||||||
case ProviderConnectionStatus = 'provider_connection.status';
|
|
||||||
case ProviderConnectionHealth = 'provider_connection.health';
|
|
||||||
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
|
|
||||||
case VerificationCheckStatus = 'verification_check_status';
|
|
||||||
case VerificationCheckSeverity = 'verification_check_severity';
|
|
||||||
case VerificationReportOverall = 'verification_report_overall';
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class BadgeSpec
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
private const ALLOWED_COLORS = [
|
|
||||||
'gray',
|
|
||||||
'info',
|
|
||||||
'success',
|
|
||||||
'warning',
|
|
||||||
'danger',
|
|
||||||
'primary',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $label,
|
|
||||||
public readonly string $color,
|
|
||||||
public readonly ?string $icon = null,
|
|
||||||
public readonly ?string $iconColor = null,
|
|
||||||
) {
|
|
||||||
if (trim($this->label) === '') {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($this->color, self::ALLOWED_COLORS, true)) {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec color must be one of: '.implode(', ', self::ALLOWED_COLORS));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->icon !== null && trim($this->icon) === '') {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec icon must be null or a non-empty string.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function allowedColors(): array
|
|
||||||
{
|
|
||||||
return self::ALLOWED_COLORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function unknown(): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
label: 'Unknown',
|
|
||||||
color: 'gray',
|
|
||||||
icon: 'heroicon-m-question-mark-circle',
|
|
||||||
iconColor: 'gray',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class FindingStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
Finding::STATUS_NEW => new BadgeSpec('New', 'warning', 'heroicon-m-clock'),
|
|
||||||
Finding::STATUS_ACKNOWLEDGED => new BadgeSpec('Acknowledged', 'gray', 'heroicon-m-check-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
|
|
||||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
|
||||||
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
OperationRunOutcome::Blocked->value => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
|
|
||||||
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
|
|
||||||
final class OperationRunStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
OperationRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
|
||||||
OperationRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
|
||||||
OperationRunStatus::Completed->value => new BadgeSpec('Completed', 'gray', 'heroicon-m-check-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class ProviderConnectionStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'connected' => new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'needs_consent' => new BadgeSpec('Needs consent', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class RestoreCheckSeverityBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'blocking' => new BadgeSpec('Blocking', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
'warning' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'safe' => new BadgeSpec('Safe', 'success', 'heroicon-m-check-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class RestoreResultStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'applied' => new BadgeSpec('Applied', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'dry_run' => new BadgeSpec('Dry run', 'info', 'heroicon-m-arrow-path'),
|
|
||||||
'mapped' => new BadgeSpec('Mapped', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
|
||||||
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'manual_required' => new BadgeSpec('Manual required', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\RestoreRunStatus;
|
|
||||||
|
|
||||||
final class RestoreRunStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
RestoreRunStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Scoped->value => new BadgeSpec('Scoped', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Checked->value => new BadgeSpec('Checked', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Previewed->value => new BadgeSpec('Previewed', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
|
||||||
RestoreRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
|
||||||
RestoreRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
|
||||||
RestoreRunStatus::Completed->value => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'),
|
|
||||||
RestoreRunStatus::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
RestoreRunStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
RestoreRunStatus::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Aborted->value => new BadgeSpec('Aborted', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::CompletedWithErrors->value => new BadgeSpec('Completed with errors', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class TenantStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
|
||||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
'suspended' => new BadgeSpec('Suspended', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Inventory;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
|
|
||||||
class InventoryKpiBadges
|
|
||||||
{
|
|
||||||
public static function coverage(int $restorableCount, int $partialCount): string
|
|
||||||
{
|
|
||||||
return Blade::render(<<<'BLADE'
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<x-filament::badge color="success" size="sm">
|
|
||||||
Restorable {{ $restorableCount }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge color="warning" size="sm">
|
|
||||||
Partial {{ $partialCount }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'restorableCount' => $restorableCount,
|
|
||||||
'partialCount' => $partialCount,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
|
|
||||||
{
|
|
||||||
return Blade::render(<<<'BLADE'
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Dependencies {{ $dependenciesCount }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge color="danger" size="sm">
|
|
||||||
Risk {{ $riskCount }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'dependenciesCount' => $dependenciesCount,
|
|
||||||
'riskCount' => $riskCount,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Inventory;
|
|
||||||
|
|
||||||
class InventoryPolicyTypeMeta
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Canonical inventory policy-type metadata source-of-truth.
|
|
||||||
*
|
|
||||||
* These definitions are used for UI classification (restore/risk) and KPI aggregation.
|
|
||||||
* The authoritative inputs are:
|
|
||||||
* - `inventory_items.policy_type`
|
|
||||||
* - `config('tenantpilot.supported_policy_types')` and `config('tenantpilot.foundation_types')`
|
|
||||||
* meta fields, especially: `restore`, `risk`.
|
|
||||||
*/
|
|
||||||
public static function all(): array
|
|
||||||
{
|
|
||||||
return array_merge(
|
|
||||||
static::supported(),
|
|
||||||
static::foundations(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function supported(): array
|
|
||||||
{
|
|
||||||
$supported = config('tenantpilot.supported_policy_types', []);
|
|
||||||
|
|
||||||
return is_array($supported) ? $supported : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function foundations(): array
|
|
||||||
{
|
|
||||||
$foundations = config('tenantpilot.foundation_types', []);
|
|
||||||
|
|
||||||
return is_array($foundations) ? $foundations : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function byType(): array
|
|
||||||
{
|
|
||||||
return collect(static::all())
|
|
||||||
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
|
||||||
->keyBy(fn (array $row): string => (string) $row['type'])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function metaFor(?string $type): array
|
|
||||||
{
|
|
||||||
if (! filled($type)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::byType()[(string) $type] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function restoreMode(?string $type): ?string
|
|
||||||
{
|
|
||||||
$restore = static::metaFor($type)['restore'] ?? null;
|
|
||||||
|
|
||||||
return is_string($restore) ? $restore : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function riskLevel(?string $type): ?string
|
|
||||||
{
|
|
||||||
$risk = static::metaFor($type)['risk'] ?? null;
|
|
||||||
|
|
||||||
return is_string($risk) ? $risk : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isRestorable(?string $type): bool
|
|
||||||
{
|
|
||||||
return static::restoreMode($type) === 'enabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isPartial(?string $type): bool
|
|
||||||
{
|
|
||||||
$restore = static::restoreMode($type);
|
|
||||||
|
|
||||||
return filled($restore) && $restore !== 'enabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isHighRisk(?string $type): bool
|
|
||||||
{
|
|
||||||
$risk = static::riskLevel($type);
|
|
||||||
|
|
||||||
return is_string($risk) && str_contains($risk, 'high');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\OperateHub;
|
|
||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
final class OperateHubShell
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private WorkspaceContext $workspaceContext,
|
|
||||||
private CapabilityResolver $capabilityResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function scopeLabel(?Request $request = null): string
|
|
||||||
{
|
|
||||||
$activeTenant = $this->activeEntitledTenant($request);
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
return 'Scope: Tenant — '.$activeTenant->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Scope: Workspace — all tenants';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, url: string}|null
|
|
||||||
*/
|
|
||||||
public function returnAffordance(?Request $request = null): ?array
|
|
||||||
{
|
|
||||||
$activeTenant = $this->activeEntitledTenant($request);
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'label' => 'Back to '.$activeTenant->name,
|
|
||||||
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
public function headerActions(
|
|
||||||
string $scopeActionName = 'operate_hub_scope',
|
|
||||||
string $returnActionName = 'operate_hub_return',
|
|
||||||
?Request $request = null,
|
|
||||||
): array {
|
|
||||||
$actions = [
|
|
||||||
Action::make($scopeActionName)
|
|
||||||
->label($this->scopeLabel($request))
|
|
||||||
->color('gray')
|
|
||||||
->disabled(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$returnAffordance = $this->returnAffordance($request);
|
|
||||||
|
|
||||||
if (is_array($returnAffordance)) {
|
|
||||||
$actions[] = Action::make($returnActionName)
|
|
||||||
->label($returnAffordance['label'])
|
|
||||||
->icon('heroicon-o-arrow-left')
|
|
||||||
->color('gray')
|
|
||||||
->url($returnAffordance['url']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
|
||||||
{
|
|
||||||
return $this->resolveActiveTenant($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveActiveTenant(?Request $request = null): ?Tenant
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rememberedTenantId = $this->workspaceContext->lastTenantId($request);
|
|
||||||
|
|
||||||
if ($rememberedTenantId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
|
|
||||||
|
|
||||||
if (! $rememberedTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->isEntitled($rememberedTenant, $request)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rememberedTenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
|
|
||||||
{
|
|
||||||
if (! $tenant->isActive()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
|
|
||||||
|
|
||||||
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->capabilityResolver->isMember($user, $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support;
|
|
||||||
|
|
||||||
use App\Support\OpsUx\OperationSummaryKeys;
|
|
||||||
|
|
||||||
final class OperationCatalog
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function labels(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'policy.sync' => 'Policy sync',
|
|
||||||
'policy.sync_one' => 'Policy sync',
|
|
||||||
'policy.capture_snapshot' => 'Policy snapshot',
|
|
||||||
'policy.delete' => 'Delete policies',
|
|
||||||
'policy.unignore' => 'Restore policies',
|
|
||||||
'policy.export' => 'Export policies to backup',
|
|
||||||
'provider.connection.check' => 'Provider connection check',
|
|
||||||
'inventory_sync' => 'Inventory sync',
|
|
||||||
'compliance.snapshot' => 'Compliance snapshot',
|
|
||||||
'entra_group_sync' => 'Directory groups sync',
|
|
||||||
'drift_generate_findings' => 'Drift generation',
|
|
||||||
'backup_set.add_policies' => 'Backup set update',
|
|
||||||
'backup_set.remove_policies' => 'Backup set update',
|
|
||||||
'backup_set.delete' => 'Archive backup sets',
|
|
||||||
'backup_set.restore' => 'Restore backup sets',
|
|
||||||
'backup_set.force_delete' => 'Delete backup sets',
|
|
||||||
'backup_schedule_run' => 'Backup schedule run',
|
|
||||||
'backup_schedule_retention' => 'Backup schedule retention',
|
|
||||||
'backup_schedule_purge' => 'Backup schedule purge',
|
|
||||||
'restore.execute' => 'Restore execution',
|
|
||||||
'assignments.fetch' => 'Assignment fetch',
|
|
||||||
'assignments.restore' => 'Assignment restore',
|
|
||||||
'ops.reconcile_adapter_runs' => 'Reconcile adapter runs',
|
|
||||||
'directory_role_definitions.sync' => 'Role definitions sync',
|
|
||||||
'restore_run.delete' => 'Delete restore runs',
|
|
||||||
'restore_run.restore' => 'Restore restore runs',
|
|
||||||
'restore_run.force_delete' => 'Force delete restore runs',
|
|
||||||
'tenant.sync' => 'Tenant sync',
|
|
||||||
'policy_version.prune' => 'Prune policy versions',
|
|
||||||
'policy_version.restore' => 'Restore policy versions',
|
|
||||||
'policy_version.force_delete' => 'Delete policy versions',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function label(string $operationType): string
|
|
||||||
{
|
|
||||||
$operationType = trim($operationType);
|
|
||||||
|
|
||||||
if ($operationType === '') {
|
|
||||||
return 'Operation';
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::labels()[$operationType] ?? 'Unknown operation';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function expectedDurationSeconds(string $operationType): ?int
|
|
||||||
{
|
|
||||||
return match (trim($operationType)) {
|
|
||||||
'policy.sync', 'policy.sync_one' => 90,
|
|
||||||
'provider.connection.check' => 30,
|
|
||||||
'policy.export' => 120,
|
|
||||||
'inventory_sync' => 180,
|
|
||||||
'compliance.snapshot' => 180,
|
|
||||||
'entra_group_sync' => 120,
|
|
||||||
'drift_generate_findings' => 240,
|
|
||||||
'assignments.fetch', 'assignments.restore' => 60,
|
|
||||||
'ops.reconcile_adapter_runs' => 120,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function allowedSummaryKeys(): array
|
|
||||||
{
|
|
||||||
return OperationSummaryKeys::all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support;
|
|
||||||
|
|
||||||
use App\Filament\Pages\DriftLanding;
|
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
|
|
||||||
final class OperationRunLinks
|
|
||||||
{
|
|
||||||
public static function index(?Tenant $tenant = null): string
|
|
||||||
{
|
|
||||||
return route('admin.operations.index');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function tenantlessView(OperationRun|int $run): string
|
|
||||||
{
|
|
||||||
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
|
|
||||||
|
|
||||||
return route('admin.operations.view', ['run' => $runId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function view(OperationRun|int $run, Tenant $tenant): string
|
|
||||||
{
|
|
||||||
return self::tenantlessView($run);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function related(OperationRun $run, ?Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
|
|
||||||
$links = [];
|
|
||||||
|
|
||||||
$links['Operations'] = self::index($tenant);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return $links;
|
|
||||||
}
|
|
||||||
|
|
||||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
|
||||||
|
|
||||||
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
|
||||||
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
|
||||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'inventory_sync') {
|
|
||||||
$links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
|
||||||
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
$policyId = $context['policy_id'] ?? null;
|
|
||||||
if (is_numeric($policyId)) {
|
|
||||||
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'entra_group_sync') {
|
|
||||||
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'drift_generate_findings') {
|
|
||||||
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
|
||||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
$backupSetId = $context['backup_set_id'] ?? null;
|
|
||||||
if (is_numeric($backupSetId)) {
|
|
||||||
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) {
|
|
||||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'restore.execute') {
|
|
||||||
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
$restoreRunId = $context['restore_run_id'] ?? null;
|
|
||||||
if (is_numeric($restoreRunId)) {
|
|
||||||
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support;
|
|
||||||
|
|
||||||
enum OperationRunType: string
|
|
||||||
{
|
|
||||||
case InventorySync = 'inventory_sync';
|
|
||||||
case PolicySync = 'policy.sync';
|
|
||||||
case PolicySyncOne = 'policy.sync_one';
|
|
||||||
case DirectoryGroupsSync = 'entra_group_sync';
|
|
||||||
case DriftGenerate = 'drift_generate_findings';
|
|
||||||
case BackupSetAddPolicies = 'backup_set.add_policies';
|
|
||||||
case BackupSetRemovePolicies = 'backup_set.remove_policies';
|
|
||||||
case BackupScheduleExecute = 'backup_schedule_run';
|
|
||||||
case BackupScheduleRetention = 'backup_schedule_retention';
|
|
||||||
case BackupSchedulePurge = 'backup_schedule_purge';
|
|
||||||
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
|
|
||||||
case RestoreExecute = 'restore.execute';
|
|
||||||
|
|
||||||
public static function values(): array
|
|
||||||
{
|
|
||||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\OpsUx;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
|
|
||||||
final class ActiveRuns
|
|
||||||
{
|
|
||||||
public static function existForTenant(Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->active()
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user