Compare commits

..

4 Commits

3608 changed files with 31082 additions and 375577 deletions

View File

@ -1,167 +0,0 @@
---
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

View File

@ -1,129 +0,0 @@
---
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

View File

@ -1,4 +0,0 @@
[mcp_servers.laravel-boost]
command = "vendor/bin/sail"
args = ["artisan", "boost:mcp"]
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"

View File

@ -1,76 +0,0 @@
---
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.

View File

@ -1,104 +0,0 @@
---
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.

View File

@ -1,12 +1,5 @@
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
@ -23,19 +16,12 @@ 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/

View File

@ -3,8 +3,6 @@ APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
SAIL_FILES=../../docker-compose.yml
TENANTATLAS_REPO_ROOT=../..
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@ -23,12 +21,11 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=127.0.0.1
DB_PORT=5432 DB_PORT=5432
FORWARD_DB_PORT=55432
DB_DATABASE=tenantatlas DB_DATABASE=tenantatlas
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=postgres DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@ -46,7 +43,7 @@ CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=redis REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
@ -76,10 +73,3 @@ ENTRA_AUTHORITY_TENANT=organizations
# System panel break-glass (Platform Operators) # System panel break-glass (Platform Operators)
BREAK_GLASS_ENABLED=false BREAK_GLASS_ENABLED=false
BREAK_GLASS_TTL_MINUTES=60 BREAK_GLASS_TTL_MINUTES=60
# Baselines (Spec 118: full-content drift detection)
TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED=false
TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200
TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5
TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3
TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90

View File

@ -2,14 +2,6 @@ # 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)
@ -37,193 +29,27 @@ ## 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 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
## Project Structure ## Project Structure
```text ```text
apps/ src/
platform/ tests/
website/
docs/
specs/
scripts/
``` ```
## Commands ## Commands
- Root workspace: # Add commands for PHP 8.4.15
- `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
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable` - 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
- 205-compare-job-cleanup: Added 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 - 090-action-surface-contract-compliance: Added PHP 8.4.15
- 204-platform-core-vocabulary-hardening: Added 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 - 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -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 `cd apps/platform && php artisan filament:assets`. - Deployment must include `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
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `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,12 +291,8 @@ ## 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 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. - 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 ## 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.
@ -376,29 +372,28 @@ ## 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.
- 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. - 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 `cd apps/platform && ./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 `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 `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. - 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.
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `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
@ -409,7 +404,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 `cd apps/platform && ./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 `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.
@ -433,10 +428,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 `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. - 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.
### Vite Error ### Vite Error
- 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`. - 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`.
=== laravel/v12 rules === === laravel/v12 rules ===
@ -465,7 +460,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 `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components. - 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. - 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.
@ -509,8 +504,8 @@ ## Testing Livewire
## Laravel Pint Code Formatter ## Laravel Pint Code Formatter
- 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. - You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- 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. - Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
@ -519,7 +514,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 `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}`.
- 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.
@ -532,9 +527,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: `cd apps/platform && ./vendor/bin/sail artisan test --compact`. - To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`. - To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- 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). - 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. - 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

View File

@ -1,76 +0,0 @@
---
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.

View File

@ -1,105 +0,0 @@
---
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.

View File

@ -1,8 +0,0 @@
---
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

View File

@ -1,167 +0,0 @@
---
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

View File

@ -1,129 +0,0 @@
---
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

20
.gitignore vendored
View File

@ -15,42 +15,22 @@
/.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
/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.*

View File

@ -1,14 +1,8 @@
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.*

View File

@ -2,22 +2,12 @@ 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

View File

@ -1,236 +0,0 @@
# 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 (02 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 |
|---|---|
| **1012** | Freigabefähig |
| **79** | Nur freigeben wenn Scope enger gezogen wird |
| **46** | Verschieben oder zu Cleanup/Micro-Follow-up downgraden |
| **03** | 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 |

View File

@ -41,52 +41,11 @@ ## 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-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified - Filament 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
- 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)
@ -150,20 +109,9 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
## Complexity Tracking ## Complexity Tracking
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.** > **Fill ONLY if Constitution Check has violations that must be justified**
| 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]

View File

@ -5,24 +5,6 @@ # 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]
@ -35,59 +17,6 @@ ## 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)*
<!-- <!--
@ -165,23 +94,6 @@ ## 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),
@ -200,91 +112,10 @@ ## 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.
@ -308,7 +139,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), whether the mutation writes an audit log, and any exemption or exception used. RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| 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 |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|

View File

@ -14,13 +14,6 @@ # 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:
@ -32,95 +25,17 @@ # 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 specs UI/UX Surface Classification for every affected surface,
- filling the specs 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 specs “UI Action Matrix” for all changed surfaces, - filling the specs “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 exactly one primary inspect/open model with the correct surface-appropriate affordance, - ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination, - enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
- 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 specs 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.
@ -267,7 +182,6 @@ ## 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
--- ---

426
Agents.md
View File

@ -25,14 +25,12 @@ ## Scope Reference
- Tenant-scoped RBAC and audit logs - Tenant-scoped RBAC and audit logs
## Workflow (Spec Kit) ## Workflow (Spec Kit)
1. Read `.specify/memory/constitution.md` 1. Read `.specify/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)
@ -318,13 +316,12 @@ ## Security
## Commands ## Commands
### Sail (preferred locally) ### Sail (preferred locally)
- `cd apps/platform && ./vendor/bin/sail up -d` - `./vendor/bin/sail up -d`
- `cd apps/platform && ./vendor/bin/sail down` - `./vendor/bin/sail down`
- `cd apps/platform && ./vendor/bin/sail composer install` - `./vendor/bin/sail composer install`
- `cd apps/platform && ./vendor/bin/sail artisan migrate` - `./vendor/bin/sail artisan migrate`
- `cd apps/platform && ./vendor/bin/sail artisan test` - `./vendor/bin/sail artisan test`
- `cd apps/platform && ./vendor/bin/sail artisan` (general) - `./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.
@ -336,10 +333,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)
- `cd apps/platform && composer install` - `composer install`
- `cd apps/platform && php artisan serve` - `php artisan serve`
- `cd apps/platform && php artisan migrate` - `php artisan migrate`
- `cd apps/platform && php artisan test` - `php artisan test`
### Frontend/assets/tooling (if present) ### Frontend/assets/tooling (if present)
- `pnpm install` - `pnpm install`
@ -353,11 +350,11 @@ ## Where to look first
- `.specify/` - `.specify/`
- `AGENTS.md` - `AGENTS.md`
- `README.md` - `README.md`
- `apps/platform/app/` - `app/`
- `apps/platform/database/` - `database/`
- `apps/platform/routes/` - `routes/`
- `apps/platform/resources/` - `resources/`
- `apps/platform/config/` - `config/`
--- ---
@ -392,7 +389,6 @@ ## 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.
@ -402,7 +398,6 @@ # 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.
@ -418,7 +413,6 @@ ## 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`.
@ -427,21 +421,19 @@ ## 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 `cd apps/platform && php artisan filament:assets`. - Deployment must include `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.
@ -455,7 +447,6 @@ ## 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.
@ -468,7 +459,6 @@ ## 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.
@ -477,7 +467,6 @@ ## 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.
@ -488,7 +477,6 @@ ## 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.
@ -498,7 +486,6 @@ ## 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(...)`.
@ -511,7 +498,6 @@ ## 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.
@ -521,7 +507,6 @@ ## 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 arent instantly obvious. - Default to explicit success/error notifications for user-triggered mutations that arent 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.
@ -530,7 +515,6 @@ ## 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.
@ -540,7 +524,6 @@ ## 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 Filaments action testing guidance. - Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests. - Do not mount non-Livewire classes in Livewire tests.
@ -550,7 +533,6 @@ ## 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+.
@ -565,7 +547,6 @@ ## 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`).
@ -586,7 +567,6 @@ ## 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).
@ -594,7 +574,6 @@ ## 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('')`).
@ -609,7 +588,6 @@ ## 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.
@ -618,21 +596,18 @@ ## 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 theres an explicit UX justification. - [ ] RelationManagers remain lazy-loaded by default unless theres 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()`.
@ -641,7 +616,6 @@ ## 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.
@ -649,39 +623,34 @@ ## 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 arent instantly obvious. - [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent 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 Filaments action testing guidance. - [ ] Actions that mutate data are covered using Filaments 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 ensure the best experience when building Laravel applications. 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.
## 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
@ -697,75 +666,56 @@ ## 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.
- 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.
- 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
- You must only create documentation files if explicitly requested by the user.
## 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.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== 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.
- 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. - The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- 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 at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. - Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- 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".
@ -775,72 +725,65 @@ ### Available Search Syntax
=== php rules === === php rules ===
# PHP ## PHP
- Always use curly braces for control structures, even for single-line bodies. - Always use curly braces for control structures, even if it has one line.
## Constructors
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }` - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<!-- Explicit Return Types and Method Params --> <code-snippet name="Explicit Return Types and Method Params" lang="php">
```php
protected function isAccessible(User $user, ?string $path = null): bool protected function isAccessible(User $user, ?string $path = null): bool
{ {
... ...
} }
``` </code-snippet>
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments ## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
- Add useful array shape type definitions when appropriate. ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== 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 `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`. - Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate` - Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install` - Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev` - Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments. - View all available Sail commands by running `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 `cd apps/platform && ./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 `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 `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. - 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.
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `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.
@ -848,53 +791,43 @@ ## 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.
## Authentication & Authorization ### Queues
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration ### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### 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 `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. - 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.
## 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
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Use the `search-docs` tool to get version-specific documentation.
- 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.
@ -902,39 +835,224 @@ ## 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 `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- 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. - Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` 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.
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`. ### Pest Tests
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`. - All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
- Do NOT delete tests without approval. - 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.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - Tests should test all of the happy paths, failure paths, and weird paths.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - Tests live in the `tests/Feature` and `tests/Unit` directories.
- 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
- Always use existing Tailwind conventions; check project patterns before adding new ones. - Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. - 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.
- 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

422
GEMINI.md
View File

@ -156,13 +156,12 @@ ## Security
## Commands ## Commands
### Sail (preferred locally) ### Sail (preferred locally)
- `cd apps/platform && ./vendor/bin/sail up -d` - `./vendor/bin/sail up -d`
- `cd apps/platform && ./vendor/bin/sail down` - `./vendor/bin/sail down`
- `cd apps/platform && ./vendor/bin/sail composer install` - `./vendor/bin/sail composer install`
- `cd apps/platform && ./vendor/bin/sail artisan migrate` - `./vendor/bin/sail artisan migrate`
- `cd apps/platform && ./vendor/bin/sail artisan test` - `./vendor/bin/sail artisan test`
- `cd apps/platform && ./vendor/bin/sail artisan` (general) - `./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.
@ -174,10 +173,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)
- `cd apps/platform && composer install` - `composer install`
- `cd apps/platform && php artisan serve` - `php artisan serve`
- `cd apps/platform && php artisan migrate` - `php artisan migrate`
- `cd apps/platform && php artisan test` - `php artisan test`
### Frontend/assets/tooling (if present) ### Frontend/assets/tooling (if present)
- `pnpm install` - `pnpm install`
@ -191,11 +190,11 @@ ## Where to look first
- `.specify/` - `.specify/`
- `AGENTS.md` - `AGENTS.md`
- `README.md` - `README.md`
- `apps/platform/app/` - `app/`
- `apps/platform/database/` - `database/`
- `apps/platform/routes/` - `routes/`
- `apps/platform/resources/` - `resources/`
- `apps/platform/config/` - `config/`
--- ---
@ -230,7 +229,6 @@ ## 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.
@ -240,7 +238,6 @@ # 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.
@ -256,7 +253,6 @@ ## 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`.
@ -265,21 +261,19 @@ ## 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 `cd apps/platform && php artisan filament:assets`. - Deployment must include `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.
@ -293,7 +287,6 @@ ## 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.
@ -306,7 +299,6 @@ ## 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.
@ -315,7 +307,6 @@ ## 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.
@ -326,7 +317,6 @@ ## 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.
@ -336,7 +326,6 @@ ## 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(...)`.
@ -349,7 +338,6 @@ ## 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.
@ -359,7 +347,6 @@ ## 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 arent instantly obvious. - Default to explicit success/error notifications for user-triggered mutations that arent 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.
@ -368,7 +355,6 @@ ## 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.
@ -378,7 +364,6 @@ ## 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 Filaments action testing guidance. - Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests. - Do not mount non-Livewire classes in Livewire tests.
@ -388,7 +373,6 @@ ## 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+.
@ -403,7 +387,6 @@ ## 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`).
@ -424,7 +407,6 @@ ## 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).
@ -432,7 +414,6 @@ ## 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('')`).
@ -447,7 +428,6 @@ ## 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.
@ -456,21 +436,18 @@ ## 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 theres an explicit UX justification. - [ ] RelationManagers remain lazy-loaded by default unless theres 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()`.
@ -479,7 +456,6 @@ ## 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.
@ -487,39 +463,34 @@ ## 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 arent instantly obvious. - [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent 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 Filaments action testing guidance. - [ ] Actions that mutate data are covered using Filaments 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 ensure the best experience when building Laravel applications. 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.
## 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
@ -535,75 +506,56 @@ ## 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.
- 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.
- 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
- You must only create documentation files if explicitly requested by the user.
## 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.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== 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.
- 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. - The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- 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 at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. - Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- 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".
@ -613,72 +565,65 @@ ### Available Search Syntax
=== php rules === === php rules ===
# PHP ## PHP
- Always use curly braces for control structures, even for single-line bodies. - Always use curly braces for control structures, even if it has one line.
## Constructors
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }` - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<!-- Explicit Return Types and Method Params --> <code-snippet name="Explicit Return Types and Method Params" lang="php">
```php
protected function isAccessible(User $user, ?string $path = null): bool protected function isAccessible(User $user, ?string $path = null): bool
{ {
... ...
} }
``` </code-snippet>
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments ## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
- Add useful array shape type definitions when appropriate. ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== 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 `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`. - Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate` - Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install` - Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev` - Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments. - View all available Sail commands by running `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 `cd apps/platform && ./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 `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 `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. - 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.
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `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.
@ -686,53 +631,43 @@ ## 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.
## Authentication & Authorization ### Queues
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration ### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### 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 `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. - 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.
## 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
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Use the `search-docs` tool to get version-specific documentation.
- 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.
@ -740,39 +675,224 @@ ## 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 `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- 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. - Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` 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.
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`. ### Pest Tests
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`. - All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
- Do NOT delete tests without approval. - 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.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - Tests should test all of the happy paths, failure paths, and weird paths.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - Tests live in the `tests/Feature` and `tests/Unit` directories.
- 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
- Always use existing Tailwind conventions; check project patterns before adding new ones. - Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. - 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.
- 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

View File

@ -5,27 +5,21 @@
**Overview:** **Overview:**
- **Purpose:** Intune management tool (backup, restore, policy versioning, safe change management) built with Laravel + Filament. - **Purpose:** Intune management tool (backup, restore, policy versioning, safe change management) built with Laravel + Filament.
- **Primary goals:** policy version control (diff/history/rollback), reliable backup & restore of Microsoft Intune configuration, admin-focused productivity features, auditability and least-privilege operations. - **Primary goals:** policy version control (diff/history/rollback), reliable backup & restore of Microsoft Intune configuration, admin-focused productivity features, auditability and least-privilege operations.
- **Workspace model:** repo-root pnpm workspace orchestration, Laravel platform in `apps/platform`, and standalone Astro website in `apps/website`.
**Tech Stack & Key Libraries:** **Tech Stack & Key Libraries:**
- **Backend:** Laravel 12 (PHP 8.4) - **Backend:** Laravel 12 (PHP 8.4)
- **Admin UI:** Filament v5 - **Admin UI:** Filament v4
- **Realtime/UI:** Livewire v4 - **Realtime/UI:** Livewire v3
- **Public website:** Astro v6
- **Workspace tooling:** pnpm 10 workspaces
- **Testing:** Pest v4, PHPUnit - **Testing:** Pest v4, PHPUnit
- **Local dev:** Laravel Sail (Docker) - **Local dev:** Laravel Sail (Docker)
- **Styling/tooling:** Tailwind v4, Vite - **Styling/tooling:** Tailwind v4, Vite
**Repository Layout (high level):** **Repository Layout (high level):**
- `apps/platform/` — Laravel runtime, Filament panels, tests, Vite assets, and app-local PHP or Node manifests - `app/` — application code (Services, Models, Filament resources, Livewire components)
- `apps/website/` — Astro website source, public assets, and static build output - `config/` — runtime configuration (important: `tenantpilot.php`, `graph_contracts.php`)
- `package.json` + `pnpm-workspace.yaml` — official root workspace command model
- `scripts/` — root compatibility helpers such as `platform-sail`
- `specs/` — SpecKit feature specs (feature-by-feature directories, e.g. `011-restore-run-wizard`) - `specs/` — SpecKit feature specs (feature-by-feature directories, e.g. `011-restore-run-wizard`)
- `docs/` — architecture, rollout, and handover notes
- `tests/` — Pest tests (Feature / Unit) - `tests/` — Pest tests (Feature / Unit)
- `apps/platform/resources/`, `apps/platform/routes/`, `apps/platform/database/` — Laravel application structure - `resources/`, `routes/`, `database/` — standard Laravel layout
**Core Features (implemented / status):** **Core Features (implemented / status):**
- **Policy Backup & Versioning:** implemented — captures immutable snapshots (JSONB), tracks metadata (tenant, type, created_by, timestamps). (See `app/Services/Intune/*`, `database/migrations`.) - **Policy Backup & Versioning:** implemented — captures immutable snapshots (JSONB), tracks metadata (tenant, type, created_by, timestamps). (See `app/Services/Intune/*`, `database/migrations`.)
@ -60,11 +54,10 @@
- Added `Agents.md` section for a “Solo + Copilot Workflow” and created a small `chore/solo-copilot-workflow` branch/PR for that documentation change. - Added `Agents.md` section for a “Solo + Copilot Workflow” and created a small `chore/solo-copilot-workflow` branch/PR for that documentation change.
**Where to look first (entry points):** **Where to look first (entry points):**
- Root workspace entry: `package.json`, `pnpm-workspace.yaml`, and `README.md` - Restore flows: `app/Services/Intune/RestoreService.php`, `app/Services/Intune/RestoreRiskChecker.php`, `app/Services/Intune/RestoreDiffGenerator.php`.
- Restore flows: `apps/platform/app/Services/Intune/RestoreService.php`, `apps/platform/app/Services/Intune/RestoreRiskChecker.php`, `apps/platform/app/Services/Intune/RestoreDiffGenerator.php` - Graph contracts: `config/graph_contracts.php` and `app/Services/Graph/GraphContractRegistry.php`.
- Graph contracts: `apps/platform/config/graph_contracts.php` and `apps/platform/app/Services/Graph/GraphContractRegistry.php` - Policy type catalog and UX metadata: `config/tenantpilot.php` and `specs/*` for feature intentions.
- Policy type catalog and UX metadata: `apps/platform/config/tenantpilot.php` and `specs/*` for feature intentions - Filament UI: `app/Filament/Resources/*` (PolicyVersionResource, restore wizard pages).
- Filament UI: `apps/platform/app/Filament/Resources/*` (PolicyVersionResource, restore wizard pages)
**Short list of known limitations / next work items:** **Short list of known limitations / next work items:**
- Convert more `preview-only` types to `enabled` where safe (requires implementation of restore flows and risk mitigation, e.g., Conditional Access, Enrollment subtypes, Security Baselines). - Convert more `preview-only` types to `enabled` where safe (requires implementation of restore flows and risk mitigation, e.g., Conditional Access, Enrollment subtypes, Security Baselines).

134
README.md
View File

@ -1,50 +1,19 @@
# TenantPilot Workspace <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 is an Intune management platform built around a stable Laravel application in <p align="center">
`apps/platform` and, starting with Spec 183, a standalone public Astro website 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/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/dt/laravel/framework" alt="Total Downloads"></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/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## Multi-App Topology ## TenantPilot setup
- `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`
## 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`
@ -56,17 +25,10 @@ ## Platform Setup Notes
- **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).
@ -77,23 +39,8 @@ ### 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: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`. - Check worker status/logs: `./vendor/bin/sail ps` and `./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
@ -117,7 +64,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: `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). - 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).
- 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
@ -142,3 +89,54 @@ ## 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).

View File

@ -3,7 +3,6 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class GraphContractCheck extends Command class GraphContractCheck extends Command
@ -12,7 +11,7 @@ class GraphContractCheck extends Command
protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)'; protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)';
public function handle(GraphClientInterface $graph, GraphContractRegistry $registry): int public function handle(GraphClientInterface $graph): int
{ {
$contracts = config('graph_contracts.types', []); $contracts = config('graph_contracts.types', []);
@ -37,13 +36,11 @@ public function handle(GraphClientInterface $graph, GraphContractRegistry $regis
continue; continue;
} }
$queryInput = array_filter([ $query = array_filter([
'$top' => 1, '$top' => 1,
'$select' => $select, '$select' => $select,
'$expand' => $expand, '$expand' => $expand,
], static fn ($value): bool => $value !== null && $value !== '' && $value !== []); ]);
$query = $registry->sanitizeQuery($type, $queryInput)['query'];
$response = $graph->request('GET', $resource, [ $response = $graph->request('GET', $resource, [
'query' => $query, 'query' => $query,

View File

@ -12,7 +12,6 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
@ -34,7 +33,7 @@ class TenantpilotPurgeNonPersistentData extends Command
* *
* @var string * @var string
*/ */
protected $description = 'Permanently delete non-persistent tenant data like policies, backups, and runs while preserving durable audit logs.'; protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
/** /**
* Execute the console command. * Execute the console command.
@ -89,6 +88,10 @@ public function handle(): int
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->delete(); ->delete();
AuditLog::query()
->where('tenant_id', $tenant->id)
->delete();
RestoreRun::withTrashed() RestoreRun::withTrashed()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->forceDelete(); ->forceDelete();
@ -147,7 +150,7 @@ private function countsForTenant(Tenant $tenant): array
return [ return [
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(), 'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(), 'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
'audit_logs_retained' => AuditLog::query()->where('tenant_id', $tenant->id)->count(), 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(), 'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(), 'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(), 'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
@ -161,8 +164,6 @@ private function countsForTenant(Tenant $tenant): array
*/ */
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
{ {
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
OperationRun::query()->create([ OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->id, 'tenant_id' => (int) $tenant->id,
@ -178,16 +179,15 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
Str::uuid()->toString(), Str::uuid()->toString(),
])), ])),
'summary_counts' => [ 'summary_counts' => [
'total' => array_sum($deletedRows), 'total' => array_sum($counts),
'processed' => array_sum($deletedRows), 'processed' => array_sum($counts),
'succeeded' => array_sum($deletedRows), 'succeeded' => array_sum($counts),
'failed' => 0, 'failed' => 0,
], ],
'failure_summary' => [], 'failure_summary' => [],
'context' => [ 'context' => [
'source' => 'tenantpilot:purge-nonpersistent', 'source' => 'tenantpilot:purge-nonpersistent',
'deleted_rows' => $deletedRows, 'deleted_rows' => $counts,
'audit_logs_retained' => $counts['audit_logs_retained'] ?? 0,
], ],
'started_at' => now(), 'started_at' => now(),
'completed_at' => now(), 'completed_at' => now(),

View File

@ -6,7 +6,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -19,10 +18,8 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.'; protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
public function handle( public function handle(OperationRunService $operationRunService): int
OperationRunService $operationRunService, {
OperationLifecycleReconciler $operationLifecycleReconciler,
): int {
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant'))); $tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
$olderThanMinutes = max(0, (int) $this->option('older-than')); $olderThanMinutes = max(0, (int) $this->option('older-than'));
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
@ -99,9 +96,31 @@ public function handle(
continue; continue;
} }
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun); if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
if (! $dryRun) {
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
}
$reconciled++;
continue;
}
if ($operationRun->status === 'running') {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule.stalled',
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
],
],
);
}
if ($change !== null) {
$reconciled++; $reconciled++;
continue; continue;

View File

@ -6,26 +6,11 @@
use BackedEnum; use BackedEnum;
use Filament\Clusters\Cluster; use Filament\Clusters\Cluster;
use Filament\Facades\Filament;
use Filament\Pages\Enums\SubNavigationPosition; use Filament\Pages\Enums\SubNavigationPosition;
use UnitEnum;
class InventoryCluster extends Cluster class InventoryCluster extends Cluster
{ {
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start; protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Items';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
} }

View File

@ -4,9 +4,6 @@
namespace App\Filament\Concerns; namespace App\Filament\Concerns;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -22,10 +19,6 @@ public static function getGlobalSearchEloquentQuery(): Builder
{ {
$query = static::getModel()::query(); $query = static::getModel()::query();
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
return $query->whereRaw('1 = 0');
}
if (! static::isScopedToTenant()) { if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel(); $panel = Filament::getCurrentOrDefaultPanel();
@ -34,7 +27,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
} }
} }
$tenant = static::resolveGlobalSearchTenant(); $tenant = Filament::getTenant();
if (! $tenant instanceof Model) { if (! $tenant instanceof Model) {
return $query->whereRaw('1 = 0'); return $query->whereRaw('1 = 0');
@ -48,17 +41,4 @@ public static function getGlobalSearchEloquentQuery(): Builder
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship); return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
} }
protected static function resolveGlobalSearchTenant(): ?Model
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
$tenant = Filament::getTenant();
return $tenant instanceof Model ? $tenant : null;
}
} }

View File

@ -0,0 +1,95 @@
<?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()]
);
}
}

View File

@ -7,11 +7,10 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl; use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver; use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -31,18 +30,33 @@ class ChooseWorkspace extends Page
protected string $view = 'filament.pages.choose-workspace'; protected string $view = 'filament.pages.choose-workspace';
/** /**
* Workspace roles keyed by workspace_id. * @return array<Action>
*
* @var array<int, string>
*/
public array $workspaceRoles = [];
/**
* @return array<\Filament\Actions\Action>
*/ */
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->visible(function (): bool {
$user = auth()->user();
return $user instanceof User
&& $user->can('create', Workspace::class);
})
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
} }
/** /**
@ -56,28 +70,15 @@ public function getWorkspaces(): Collection
return Workspace::query()->whereRaw('1 = 0')->get(); return Workspace::query()->whereRaw('1 = 0')->get();
} }
$workspaces = Workspace::query() return Workspace::query()
->whereIn('id', function ($query) use ($user): void { ->whereIn('id', function ($query) use ($user): void {
$query->from('workspace_memberships') $query->from('workspace_memberships')
->select('workspace_id') ->select('workspace_id')
->where('user_id', $user->getKey()); ->where('user_id', $user->getKey());
}) })
->whereNull('archived_at') ->whereNull('archived_at')
->withCount(['tenants' => function ($query): void {
$query->where('status', 'active');
}])
->orderBy('name') ->orderBy('name')
->get(); ->get();
// Build roles map from memberships.
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->whereIn('workspace_id', $workspaces->pluck('id'))
->pluck('role', 'workspace_id');
$this->workspaceRoles = $memberships->mapWithKeys(fn ($role, $id) => [(int) $id => (string) $role])->all();
return $workspaces;
} }
public function selectWorkspace(int $workspaceId): void public function selectWorkspace(int $workspaceId): void
@ -104,37 +105,11 @@ public function selectWorkspace(int $workspaceId): void
abort(404); abort(404);
} }
$prevWorkspaceId = $context->currentWorkspaceId(request());
$context->setCurrentWorkspace($workspace, $user, request()); $context->setCurrentWorkspace($workspace, $user, request());
// Audit: manual workspace selection.
/** @var WorkspaceAuditLogger $logger */
$logger = app(WorkspaceAuditLogger::class);
$logger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSelected->value,
context: [
'metadata' => [
'method' => 'manual',
'reason' => 'chooser',
'prev_workspace_id' => $prevWorkspaceId,
],
],
actor: $user,
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume(request()); $intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */ $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
$resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$this->redirect($redirectTarget);
} }
/** /**
@ -172,11 +147,41 @@ public function createWorkspace(array $data): void
$intendedUrl = WorkspaceIntendedUrl::consume(request()); $intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */ $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
$resolver = app(WorkspaceRedirectResolver::class); }
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user); private function redirectAfterWorkspaceSelected(User $user): string
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$this->redirect($redirectTarget); if ($workspaceId === null) {
return self::getUrl();
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return self::getUrl();
}
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
}
}
return ChooseTenant::getUrl();
} }
} }

View File

@ -0,0 +1,298 @@
<?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());
}
}

View File

@ -0,0 +1,86 @@
<?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();
}
}

View File

@ -0,0 +1,41 @@
<?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',
);
}
}

View File

@ -0,0 +1,41 @@
<?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',
);
}
}

View File

@ -0,0 +1,149 @@
<?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,
};
}
}

View File

@ -7,9 +7,6 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -30,16 +27,6 @@ class NoAccess extends Page
protected string $view = 'filament.pages.no-access'; protected string $view = 'filament.pages.no-access';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header provides a create-workspace recovery action when the user has no tenant access yet.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The no-access page is a singleton recovery surface without record-level inspect affordances.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The no-access page does not render row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The no-access page does not expose bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a dedicated recovery message instead of a list-style empty state.');
}
/** /**
* @return array<Action> * @return array<Action>
*/ */

View File

@ -0,0 +1,142 @@
<?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'),
]);
}
}

View File

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Settings;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class WorkspaceSettings extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'settings/workspace';
protected static ?string $title = 'Workspace settings';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static ?int $navigationSort = 20;
public Workspace $workspace;
/**
* @var array<string, mixed>
*/
public array $data = [];
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('save')
->label('Save')
->action(function (): void {
$this->save();
})
->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage()
? null
: 'You do not have permission to manage workspace settings.'),
Action::make('reset')
->label('Reset to default')
->color('danger')
->requiresConfirmation()
->action(function (): void {
$this->resetSetting();
})
->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage()
? null
: 'You do not have permission to manage workspace settings.'),
];
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide save and reset controls for the settings form.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.');
}
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
$this->redirect('/admin/choose-workspace');
return;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
$this->workspace = $workspace;
$this->authorizeWorkspaceView($user);
$this->loadFormState();
}
public function content(Schema $schema): Schema
{
return $schema
->statePath('data')
->schema([
Section::make('Backup settings')
->description('Workspace defaults used when a schedule has no explicit value.')
->schema([
TextInput::make('backup_retention_keep_last_default')
->label('Default retention keep-last')
->numeric()
->integer()
->minValue(1)
->required()
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText('Fallback value for backup schedule retention when retention_keep_last is empty.'),
]),
]);
}
public function save(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
try {
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: 'backup',
key: 'retention_keep_last_default',
value: $this->data['backup_retention_keep_last_default'] ?? null,
);
} catch (ValidationException $exception) {
$errors = $exception->errors();
if (isset($errors['value'])) {
throw ValidationException::withMessages([
'data.backup_retention_keep_last_default' => $errors['value'],
]);
}
throw $exception;
}
$this->loadFormState();
Notification::make()
->title('Workspace settings saved')
->success()
->send();
}
public function resetSetting(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
app(SettingsWriter::class)->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: 'backup',
key: 'retention_keep_last_default',
);
$this->loadFormState();
Notification::make()
->title('Workspace setting reset to default')
->success()
->send();
}
private function loadFormState(): void
{
$resolvedValue = app(SettingsResolver::class)->resolveValue(
workspace: $this->workspace,
domain: 'backup',
key: 'retention_keep_last_default',
);
$this->data = [
'backup_retention_keep_last_default' => is_numeric($resolvedValue) ? (int) $resolvedValue : 30,
];
}
private function currentUserCanManage(): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $this->workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $this->workspace)
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
}
private function authorizeWorkspaceView(User $user): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $this->workspace)) {
abort(404);
}
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) {
abort(403);
}
}
private function authorizeWorkspaceManage(User $user): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $this->workspace)) {
abort(404);
}
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
abort(403);
}
}
}

View File

@ -4,13 +4,10 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings; use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations; use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
use Filament\Pages\Dashboard; use Filament\Pages\Dashboard;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration; use Filament\Widgets\WidgetConfiguration;
@ -32,11 +29,8 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
public function getWidgets(): array public function getWidgets(): array
{ {
return [ return [
TenantTriageArrivalContinuity::class,
RecoveryReadiness::class,
DashboardKpis::class, DashboardKpis::class,
NeedsAttention::class, NeedsAttention::class,
BaselineCompareNow::class,
RecentDriftFindings::class, RecentDriftFindings::class,
RecentOperations::class, RecentOperations::class,
]; ];

View File

@ -4,7 +4,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Models\Tenant;
use App\Models\TenantMembership; use App\Models\TenantMembership;
use App\Models\User; use App\Models\User;
use App\Services\Auth\TenantDiagnosticsService; use App\Services\Auth\TenantDiagnosticsService;
@ -12,39 +12,24 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Pages\Page; use Filament\Pages\Page;
class TenantDiagnostics extends Page class TenantDiagnostics extends Page
{ {
use ResolvesPanelTenantContext;
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'diagnostics'; protected static ?string $slug = 'diagnostics';
protected string $view = 'filament.pages.tenant-diagnostics'; protected string $view = 'filament.pages.tenant-diagnostics';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.');
}
public bool $missingOwner = false; public bool $missingOwner = false;
public bool $hasDuplicateMembershipsForCurrentUser = false; public bool $hasDuplicateMembershipsForCurrentUser = false;
public function mount(): void public function mount(): void
{ {
$tenant = static::resolveTenantContextForCurrentPanelOrFail(); $tenant = Tenant::current();
$tenantId = (int) $tenant->getKey(); $tenantId = (int) $tenant->getKey();
$this->missingOwner = ! TenantMembership::query() $this->missingOwner = ! TenantMembership::query()
@ -95,7 +80,7 @@ protected function getHeaderActions(): array
public function bootstrapOwner(): void public function bootstrapOwner(): void
{ {
$tenant = static::resolveTenantContextForCurrentPanelOrFail(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
@ -109,7 +94,7 @@ public function bootstrapOwner(): void
public function mergeDuplicateMemberships(): void public function mergeDuplicateMemberships(): void
{ {
$tenant = static::resolveTenantContextForCurrentPanelOrFail(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {

View File

@ -0,0 +1,228 @@
<?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

View File

@ -5,14 +5,10 @@
namespace App\Filament\Pages\Workspaces; namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Resources\TenantResource; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -22,26 +18,12 @@ class ManagedTenantsLanding extends Page
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
protected static string $layout = 'filament-panels::components.layout.simple';
protected static ?string $title = 'Managed tenants'; protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.workspaces.managed-tenants-landing'; protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
public Workspace $workspace; public Workspace $workspace;
/**
* The Filament simple layout renders the topbar by default, which includes
* lazy-loaded database notifications. On this workspace-scoped landing page,
* those background Livewire requests currently 404.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
public function mount(Workspace $workspace): void public function mount(Workspace $workspace): void
{ {
$this->workspace = $workspace; $this->workspace = $workspace;
@ -58,25 +40,11 @@ public function getTenants(): Collection
return Tenant::query()->whereRaw('1 = 0')->get(); return Tenant::query()->whereRaw('1 = 0')->get();
} }
$tenantIds = $user->tenantMemberships() return $user->tenants()
->pluck('tenant_id');
return Tenant::query()
->withTrashed()
->whereIn('id', $tenantIds)
->where('workspace_id', $this->workspace->getKey()) ->where('workspace_id', $this->workspace->getKey())
->where('status', 'active')
->orderBy('name') ->orderBy('name')
->get() ->get();
->filter(function (Tenant $tenant) use ($user): bool {
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
actor: $user,
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
lane: TenantInteractionLane::AdministrativeManagement,
)->allowed;
})
->values();
} }
public function goToChooseTenant(): void public function goToChooseTenant(): void
@ -93,7 +61,7 @@ public function openTenant(int $tenantId): void
} }
$tenant = Tenant::query() $tenant = Tenant::query()
->withTrashed() ->where('status', 'active')
->where('workspace_id', $this->workspace->getKey()) ->where('workspace_id', $this->workspace->getKey())
->whereKey($tenantId) ->whereKey($tenantId)
->first(); ->first();
@ -106,6 +74,6 @@ public function openTenant(int $tenantId): void
abort(404); abort(404);
} }
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant])); $this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
} }
} }

View File

@ -3,8 +3,6 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Exceptions\InvalidPolicyTypeException; use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupScheduleResource\Pages; use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
use App\Jobs\RunBackupScheduleJob; use App\Jobs\RunBackupScheduleJob;
@ -22,7 +20,6 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -32,15 +29,13 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use BackedEnum; use BackedEnum;
use DateTimeZone; use DateTimeZone;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction; use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -65,9 +60,6 @@
class BackupScheduleResource extends Resource class BackupScheduleResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = BackupSchedule::class; protected static ?string $model = BackupSchedule::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -76,18 +68,9 @@ class BackupScheduleResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -103,7 +86,7 @@ public static function canViewAny(): bool
public static function canView(Model $record): bool public static function canView(Model $record): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -127,7 +110,7 @@ public static function canView(Model $record): bool
public static function canCreate(): bool public static function canCreate(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -143,7 +126,7 @@ public static function canCreate(): bool
public static function canEdit(Model $record): bool public static function canEdit(Model $record): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -159,7 +142,7 @@ public static function canEdit(Model $record): bool
public static function canDelete(Model $record): bool public static function canDelete(Model $record): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -175,7 +158,7 @@ public static function canDelete(Model $record): bool
public static function canDeleteAny(): bool public static function canDeleteAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -191,11 +174,11 @@ public static function canDeleteAny(): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit, ActionSurfaceType::CrudListFirstResource) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.') ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More" in workflow-first, destructive-last order.') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in workflow-first, destructive-last order.') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.');
} }
@ -270,24 +253,10 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function makeCreateAction(): CreateAction
{
return CreateAction::make()
->label('New backup schedule')
->disabled(fn (): bool => ! static::canCreate())
->tooltip(fn (): ?string => static::canCreate()
? null
: 'You do not have permission to create backup schedules.');
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->defaultSort('next_run_at', 'asc') ->defaultSort('next_run_at', 'asc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record) ->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record]) ? static::getUrl('edit', ['record' => $record])
: null) : null)
@ -303,7 +272,6 @@ public static function table(Table $table): Table
TextColumn::make('name') TextColumn::make('name')
->searchable() ->searchable()
->sortable()
->label('Schedule'), ->label('Schedule'),
TextColumn::make('frequency') TextColumn::make('frequency')
@ -317,8 +285,7 @@ public static function table(Table $table): Table
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null), ->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
TextColumn::make('timezone') TextColumn::make('timezone')
->label('Timezone') ->label('Timezone'),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_types') TextColumn::make('policy_types')
->label('Policy types') ->label('Policy types')
@ -327,8 +294,7 @@ public static function table(Table $table): Table
TextColumn::make('retention_keep_last') TextColumn::make('retention_keep_last')
->label('Retention') ->label('Retention')
->suffix(' sets') ->suffix(' sets'),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('last_run_status') TextColumn::make('last_run_status')
->label('Last run status') ->label('Last run status')
@ -370,8 +336,7 @@ public static function table(Table $table): Table
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome); $spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}) }),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('last_run_at') TextColumn::make('last_run_at')
->label('Last run') ->label('Last run')
@ -395,7 +360,6 @@ public static function table(Table $table): Table
return $nextRun->format('M j, Y H:i:s'); return $nextRun->format('M j, Y H:i:s');
} }
}) })
->description(fn (BackupSchedule $record): ?string => static::scheduleFollowUpDescription($record))
->sortable(), ->sortable(),
]) ])
->filters([ ->filters([
@ -437,7 +401,7 @@ public static function table(Table $table): Table
->color('success') ->color('success')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, HasTable $livewire): void { ->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -492,7 +456,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $operationRun->type) OperationUxPresenter::queuedToast((string) $operationRun->type)
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label(OperationRunLinks::openLabel()) ->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)), ->url(OperationRunLinks::view($operationRun, $tenant)),
]) ])
->send(); ->send();
@ -508,7 +472,7 @@ public static function table(Table $table): Table
->color('warning') ->color('warning')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, HasTable $livewire): void { ->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -563,7 +527,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $operationRun->type) OperationUxPresenter::queuedToast((string) $operationRun->type)
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label(OperationRunLinks::openLabel()) ->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)), ->url(OperationRunLinks::view($operationRun, $tenant)),
]) ])
->send(); ->send();
@ -573,45 +537,8 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Action::make('restore') EditAction::make()
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('restore', $record);
if (! $record->trashed()) {
return;
}
$record->restore();
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup_schedule.restored',
resourceType: 'backup_schedule',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->getKey(),
'backup_schedule_name' => $record->name,
],
],
);
}
Notification::make()
->title('Backup schedule restored')
->success()
->send();
})
) )
->preserveVisibility()
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
@ -619,11 +546,8 @@ public static function table(Table $table): Table
->label('Archive') ->label('Archive')
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->color('danger') ->color('danger')
->requiresConfirmation()
->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('delete', $record); Gate::authorize('delete', $record);
if ($record->trashed()) { if ($record->trashed()) {
@ -658,16 +582,53 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->destructive() ->destructive()
->apply(), ->apply(),
UiEnforcement::forAction(
Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
Gate::authorize('restore', $record);
if (! $record->trashed()) {
return;
}
$record->restore();
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup_schedule.restored',
resourceType: 'backup_schedule',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->getKey(),
'backup_schedule_name' => $record->name,
],
],
);
}
Notification::make()
->title('Backup schedule restored')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Action::make('forceDelete') Action::make('forceDelete')
->label('Force delete') ->label('Force delete')
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->color('danger') ->color('danger')
->requiresConfirmation()
->visible(fn (BackupSchedule $record): bool => $record->trashed()) ->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('forceDelete', $record); Gate::authorize('forceDelete', $record);
if (! $record->trashed()) { if (! $record->trashed()) {
@ -725,7 +686,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -740,7 +701,7 @@ public static function table(Table $table): Table
return; return;
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$userId = auth()->id(); $userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null; $user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */ /** @var OperationRunService $operationRunService */
@ -804,7 +765,7 @@ public static function table(Table $table): Table
Action::make('view_runs') Action::make('view_runs')
->label('View in Operations') ->label('View in Operations')
->url(OperationRunLinks::index($tenant)), ->url(OperationRunLinks::index($tenant)),
]); ])->sendToDatabase($user);
} }
$notification->send(); $notification->send();
@ -822,7 +783,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('warning')
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -837,7 +798,7 @@ public static function table(Table $table): Table
return; return;
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$userId = auth()->id(); $userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null; $user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */ /** @var OperationRunService $operationRunService */
@ -901,7 +862,7 @@ public static function table(Table $table): Table
Action::make('view_runs') Action::make('view_runs')
->label('View in Operations') ->label('View in Operations')
->url(OperationRunLinks::index($tenant)), ->url(OperationRunLinks::index($tenant)),
]); ])->sendToDatabase($user);
} }
$notification->send(); $notification->send();
@ -914,43 +875,22 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(), ->apply(),
])->label('More'), ])->label('More'),
])
->emptyStateHeading('No schedules configured')
->emptyStateDescription('Set up automated backups.')
->emptyStateIcon('heroicon-o-clock')
->emptyStateActions([
static::makeCreateAction(),
]); ]);
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
return static::getTenantOwnedEloquentQuery() $tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->where('tenant_id', $tenantId)
->orderByDesc('is_enabled') ->orderByDesc('is_enabled')
->orderBy('next_run_at'); ->orderBy('next_run_at');
} }
public static function getRecordRouteBindingEloquentQuery(): Builder public static function getRecordRouteBindingEloquentQuery(): Builder
{ {
return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed()) return static::getEloquentQuery()->withTrashed();
->orderByDesc('is_enabled')
->orderBy('next_run_at');
}
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
}
protected static function resolveProtectedScheduleRecordOrFail(BackupSchedule|int|string $record): BackupSchedule
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof BackupSchedule) {
abort(404);
}
return $resolvedRecord;
} }
public static function getRelations(): array public static function getRelations(): array
@ -1061,7 +1001,7 @@ public static function ensurePolicyTypes(array $data): array
public static function assignTenant(array $data): array public static function assignTenant(array $data): array
{ {
$data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey(); $data['tenant_id'] = Tenant::currentOrFail()->getKey();
return $data; return $data;
} }
@ -1150,31 +1090,4 @@ protected static function dayOfWeekOptions(): array
7 => 'Sunday', 7 => 'Sunday',
]; ];
} }
protected static function scheduleFollowUpDescription(BackupSchedule $record): ?string
{
if (! $record->is_enabled || $record->trashed()) {
return null;
}
$graceCutoff = now('UTC')->subMinutes(max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30)));
$lastRunStatus = strtolower(trim((string) $record->last_run_status));
$isOverdue = $record->next_run_at?->lessThan($graceCutoff) ?? false;
$neverSuccessful = $record->last_run_at === null
&& ($isOverdue || ($record->created_at?->lessThan($graceCutoff) ?? false));
if ($neverSuccessful) {
return 'No successful run has been recorded yet.';
}
if ($isOverdue) {
return 'This schedule looks overdue.';
}
if (in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true)) {
return 'The last run needs follow-up.';
}
return null;
}
} }

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class EditBackupSchedule extends EditRecord class EditBackupSchedule extends EditRecord
{ {
@ -12,7 +13,15 @@ class EditBackupSchedule extends EditRecord
protected function resolveRecord(int|string $key): Model protected function resolveRecord(int|string $key): Model
{ {
return BackupScheduleResource::resolveScopedRecordOrFail($key); $record = BackupScheduleResource::getEloquentQuery()
->withTrashed()
->find($key);
if ($record === null) {
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
}
return $record;
} }
protected function mutateFormDataBeforeSave(array $data): array protected function mutateFormDataBeforeSave(array $data): array

View File

@ -0,0 +1,48 @@
<?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.');
}
}

View File

@ -12,7 +12,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Closure; use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -28,8 +28,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.') ->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary because this relation manager has no secondary row actions.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.'); ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
} }
@ -39,22 +39,14 @@ public function table(Table $table): Table
return $table return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())) ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->recordUrl(function (OperationRun $record): string {
$record = $this->resolveOwnerScopedOperationRun($record);
$tenant = Tenant::currentOrFail();
return OperationRunLinks::view($record, $tenant);
})
->columns([ ->columns([
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->label('Enqueued') ->label('Enqueued')
->dateTime() ->dateTime(),
->sortable(),
Tables\Columns\TextColumn::make('type') Tables\Columns\TextColumn::make('type')
->label('Type') ->label('Type')
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])), ->formatStateUsing([OperationCatalog::class, 'label']),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
@ -88,37 +80,17 @@ public function table(Table $table): Table
]) ])
->filters([]) ->filters([])
->headerActions([]) ->headerActions([])
->actions([]) ->actions([
->bulkActions([]) Actions\Action::make('view')
->emptyStateHeading('No schedule runs yet') ->label('View')
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.'); ->icon('heroicon-o-eye')
} ->url(function (OperationRun $record): string {
$tenant = Tenant::currentOrFail();
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun return OperationRunLinks::view($record, $tenant);
{ })
$recordId = $record instanceof OperationRun ->openUrlInNewTab(true),
? (int) $record->getKey() ])
: (is_numeric($record) ? (int) $record : 0); ->bulkActions([]);
if ($recordId <= 0) {
abort(404);
}
$resolvedRecord = $this->getOwnerRecord()
->operationRuns()
->where('tenant_id', Tenant::currentOrFail()->getKey())
->whereKey($recordId)
->first();
if (! $resolvedRecord instanceof OperationRun) {
abort(404);
}
return $resolvedRecord;
}
public static function formatOperationType(?string $state): string
{
return OperationCatalog::label($state);
} }
} }

View File

@ -2,8 +2,6 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Jobs\BulkBackupSetDeleteJob; use App\Jobs\BulkBackupSetDeleteJob;
@ -18,26 +16,11 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -53,16 +36,11 @@
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use UnitEnum; use UnitEnum;
class BackupSetResource extends Resource class BackupSetResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = BackupSet::class; protected static ?string $model = BackupSet::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -71,29 +49,9 @@ class BackupSetResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -109,7 +67,7 @@ public static function canViewAny(): bool
public static function canCreate(): bool public static function canCreate(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -123,16 +81,6 @@ public static function canCreate(): bool
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); && $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
} }
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery();
}
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -144,54 +92,21 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function makeCreateAction(): Actions\CreateAction
{
$action = Actions\CreateAction::make()
->label('Create backup set');
UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return $action;
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->defaultSort('created_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
'items' => fn ($itemQuery) => $itemQuery->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]))
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') Tables\Columns\TextColumn::make('name')->searchable(),
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus)) ->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(), Tables\Columns\TextColumn::make('item_count')->label('Items'),
Tables\Columns\TextColumn::make('backup_quality') Tables\Columns\TextColumn::make('created_by')->label('Created by'),
->label('Backup quality') Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
->state(fn (BackupSet $record): string => static::backupQualitySummary($record)->compactSummary) Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
->description(fn (BackupSet $record): string => static::backupQualitySummary($record)->nextAction)
->wrap(),
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
Tables\Filters\TrashedFilter::make() Tables\Filters\TrashedFilter::make()
@ -200,9 +115,10 @@ public static function table(Table $table): Table
->trueLabel('All') ->trueLabel('All')
->falseLabel('Archived'), ->falseLabel('Archived'),
]) ])
->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record]))
->actions([ ->actions([
static::primaryRelatedAction(), Actions\ViewAction::make()
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
ActionGroup::make([ ActionGroup::make([
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('restore') Actions\Action::make('restore')
@ -212,7 +128,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed()) ->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Filament::getTenant();
$record->restore(); $record->restore();
$record->items()->withTrashed()->restore(); $record->items()->withTrashed()->restore();
@ -245,7 +161,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed()) ->visible(fn (BackupSet $record): bool => ! $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Filament::getTenant();
$record->delete(); $record->delete();
@ -277,7 +193,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed()) ->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Filament::getTenant();
if ($record->restoreRuns()->withTrashed()->exists()) { if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make() Notification::make()
@ -347,7 +263,7 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -390,7 +306,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('backup_set.delete') OperationUxPresenter::queuedToast('backup_set.delete')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel()) ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -417,7 +333,7 @@ public static function table(Table $table): Table
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -460,7 +376,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('backup_set.restore') OperationUxPresenter::queuedToast('backup_set.restore')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel()) ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -502,7 +418,7 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -545,7 +461,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('backup_set.force_delete') OperationUxPresenter::queuedToast('backup_set.force_delete')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel()) ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -555,12 +471,6 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_DELETE) ->requireCapability(Capabilities::TENANT_DELETE)
->apply(), ->apply(),
])->label('More'), ])->label('More'),
])
->emptyStateHeading('No backup sets')
->emptyStateDescription('Create a backup set to start protecting your configurations.')
->emptyStateIcon('heroicon-o-archive-box')
->emptyStateActions([
static::makeCreateAction(),
]); ]);
} }
@ -568,11 +478,21 @@ public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Infolists\Components\ViewEntry::make('enterprise_detail') Infolists\Components\TextEntry::make('name'),
->label('') Infolists\Components\TextEntry::make('status')
->view('filament.infolists.entries.enterprise-detail.layout') ->badge()
->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray()) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->columnSpanFull(), ->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Infolists\Components\TextEntry::make('item_count')->label('Items'),
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
Infolists\Components\TextEntry::make('metadata')
->label('Metadata')
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
->copyable()
->copyMessage('Metadata copied'),
]); ]);
} }
@ -610,49 +530,13 @@ private static function typeMeta(?string $type): array
->firstWhere('type', $type) ?? []; ->firstWhere('type', $type) ?? [];
} }
/**
* @return list<array{
* key: string,
* label: string,
* value: string,
* secondaryValue: ?string,
* targetUrl: ?string,
* targetKind: string,
* availability: string,
* unavailableReason: ?string,
* contextBadge: ?string,
* priority: int,
* actionLabel: string
* }>
*/
public static function relatedContextEntries(BackupSet $record): array
{
return app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
}
private static function primaryRelatedAction(): Actions\Action
{
return Actions\Action::make('primary_drill_down')
->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
->color('gray');
}
private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
}
/** /**
* Create a backup set via the domain service instead of direct model mass-assignment. * Create a backup set via the domain service instead of direct model mass-assignment.
*/ */
public static function createBackupSet(array $data): BackupSet public static function createBackupSet(array $data): BackupSet
{ {
/** @var Tenant $tenant */ /** @var Tenant $tenant */
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
/** @var BackupService $service */ /** @var BackupService $service */
$service = app(BackupService::class); $service = app(BackupService::class);
@ -667,204 +551,4 @@ public static function createBackupSet(array $data): BackupSet
includeScopeTags: $data['include_scope_tags'] ?? false, includeScopeTags: $data['include_scope_tags'] ?? false,
); );
} }
private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData
{
$factory = new EnterpriseDetailSectionFactory;
$statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status);
$metadata = is_array($record->metadata) ? $record->metadata : [];
$metadataKeyCount = count($metadata);
$relatedContext = static::relatedContextEntries($record);
$isArchived = $record->trashed();
$qualitySummary = static::backupQualitySummary($record);
$backupHealthAssessment = static::backupHealthContinuityAssessment($record);
$qualityBadge = match (true) {
$qualitySummary->totalItems === 0 => $factory->statusBadge('No items', 'gray'),
$qualitySummary->hasDegradations() => $factory->statusBadge('Degraded input', 'warning', 'heroicon-m-exclamation-triangle'),
default => $factory->statusBadge('No degradations', 'success', 'heroicon-m-check-circle'),
};
$backupHealthBadge = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->statusBadge(
static::backupHealthContinuityLabel($backupHealthAssessment),
$backupHealthAssessment->tone(),
'heroicon-m-exclamation-triangle',
)
: null;
$descriptionHint = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? trim($backupHealthAssessment->headline.' '.($backupHealthAssessment->supportingMessage ?? ''))
: 'Backup quality, lifecycle status, and related operations stay ahead of raw backup metadata.';
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
->header(new SummaryHeaderData(
title: (string) $record->name,
subtitle: 'Backup set #'.$record->getKey(),
statusBadges: [
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
...array_filter([$backupHealthBadge]),
$qualityBadge,
],
keyFacts: [
$factory->keyFact('Items', $record->item_count),
...array_filter([
$backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
: null,
]),
$factory->keyFact('Backup quality', $qualitySummary->compactSummary),
$factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
],
descriptionHint: $descriptionHint,
))
->decisionZone($factory->decisionZone(
facts: array_values(array_filter([
$backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
: null,
$factory->keyFact('Backup quality', $qualitySummary->compactSummary, badge: $qualityBadge),
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
$factory->keyFact('Integrity warnings', $qualitySummary->integrityWarningCount),
$qualitySummary->unknownQualityCount > 0
? $factory->keyFact('Unknown quality', $qualitySummary->unknownQualityCount)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$qualitySummary->nextAction,
'Backup quality',
),
description: 'Start here to judge whether this backup set looks strong or weak as restore input before reading diagnostics or raw metadata.',
compactCounts: $factory->countPresentation(summaryLine: $qualitySummary->summaryMessage),
attentionNote: $backupHealthAssessment?->positiveClaimBoundary ?? $qualitySummary->positiveClaimBoundary,
title: 'Backup quality',
))
->addSection(
$factory->factsSection(
id: 'lifecycle_overview',
kind: 'core_details',
title: 'Lifecycle overview',
items: [
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Items', $record->item_count),
$factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Archived', $isArchived),
],
),
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => $relatedContext],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
)
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'status',
title: 'Backup quality counts',
items: [
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
],
),
$factory->supportingFactsCard(
kind: 'timestamps',
title: 'Timing',
items: [
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
],
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Technical detail',
entries: [
$factory->keyFact('Metadata keys', $metadataKeyCount),
$factory->keyFact('Archived', $isArchived),
],
description: 'Low-signal backup metadata stays available here without taking over the recovery workflow.',
view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null,
viewData: ['payload' => $metadata],
emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'),
),
)
->build();
}
private static function formatDetailTimestamp(mixed $value): string
{
if (! $value instanceof Carbon) {
return '—';
}
return $value->toDayDateTimeString();
}
private static function backupQualitySummary(BackupSet $record): \App\Support\BackupQuality\BackupQualitySummary
{
if ($record->trashed()) {
$record->setRelation('items', $record->items()->withTrashed()->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
])->get());
} elseif (! $record->relationLoaded('items')) {
$record->loadMissing([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]);
}
return app(BackupQualityResolver::class)->summarizeBackupSet($record);
}
private static function backupHealthContinuityAssessment(BackupSet $record): ?TenantBackupHealthAssessment
{
$requestedReason = request()->string('backup_health_reason')->toString();
if (! in_array($requestedReason, [
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
], true)) {
return null;
}
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
$assessment = $resolver->assess((int) $record->tenant_id);
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
return null;
}
if ($assessment->primaryReason !== $requestedReason) {
return null;
}
return $assessment;
}
private static function backupHealthContinuityLabel(TenantBackupHealthAssessment $assessment): string
{
return match ($assessment->primaryReason) {
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
default => ucfirst($assessment->posture),
};
}
} }

View File

@ -0,0 +1,43 @@
<?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,
];
}
}

View File

@ -0,0 +1,11 @@
<?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;
}

View File

@ -3,34 +3,24 @@
namespace App\Filament\Resources\BackupSetResource\RelationManagers; namespace App\Filament\Resources\BackupSetResource\RelationManagers;
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Jobs\RemovePoliciesFromBackupSetJob; use App\Jobs\RemovePoliciesFromBackupSetJob;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -49,37 +39,6 @@ public function closeAddPoliciesModal(): void
$this->unmountAction(); $this->unmountAction();
} }
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true) {
$backupSet = $this->getOwnerRecord();
if ($name === 'remove' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']);
}
if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) {
$this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
}
}
return parent::mountAction($name, $arguments, $context);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->satisfy(ActionSurfaceSlot::ListHeader, 'Refresh and Add Policies actions are available in the relation header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Remove remains grouped under More.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk remove remains grouped under More.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the Add Policies CTA.');
}
public function table(Table $table): Table public function table(Table $table): Table
{ {
$refreshTable = Actions\Action::make('refreshTable') $refreshTable = Actions\Action::make('refreshTable')
@ -114,7 +73,7 @@ public function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->requiresConfirmation() ->requiresConfirmation()
->action(function (mixed $record): void { ->action(function (BackupItem $record): void {
$backupSet = $this->getOwnerRecord(); $backupSet = $this->getOwnerRecord();
$user = auth()->user(); $user = auth()->user();
@ -131,7 +90,7 @@ public function table(Table $table): Table
abort(404); abort(404);
} }
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)]; $backupItemIds = [(int) $record->getKey()];
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -146,12 +105,13 @@ public function table(Table $table): Table
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Removal already queued')
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->body('A matching remove operation is already queued or running.')
->info()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -172,7 +132,7 @@ public function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $opRun->type) OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -210,7 +170,14 @@ public function table(Table $table): Table
abort(404); abort(404);
} }
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords); $backupItemIds = $records
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($backupItemIds === []) { if ($backupItemIds === []) {
return; return;
@ -229,12 +196,13 @@ public function table(Table $table): Table
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Removal already queued')
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->body('A matching remove operation is already queued or running.')
->info()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -255,7 +223,7 @@ public function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $opRun->type) OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -267,45 +235,18 @@ public function table(Table $table): Table
->apply(); ->apply();
return $table return $table
->modifyQueryUsing(fn (Builder $query) => $query->with(['policy', 'policyVersion', 'policyVersion.policy'])) ->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->defaultSort('policy.display_name')
->paginated(TablePaginationProfiles::relationManager())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (BackupItem $record): ?string => $this->backupItemInspectUrl($record))
->columns([ ->columns([
Tables\Columns\TextColumn::make('policy.display_name') Tables\Columns\TextColumn::make('policy.display_name')
->label('Item') ->label('Item')
->sortable() ->sortable()
->searchable() ->searchable()
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()), ->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
Tables\Columns\TextColumn::make('snapshot_mode')
->label('Snapshot')
->badge()
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Tables\Columns\TextColumn::make('policyVersion.version_number') Tables\Columns\TextColumn::make('policyVersion.version_number')
->label('Version') ->label('Version')
->badge() ->badge()
->default('—') ->default('—')
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number), ->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
Tables\Columns\TextColumn::make('backup_quality')
->label('Backup quality')
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->compactSummary)
->description(function (BackupItem $record): string {
$summary = $this->backupItemQualitySummary($record);
if ($summary->assignmentCaptureReason === 'separate_role_assignments') {
return 'Assignments are captured separately for this item type.';
}
return $summary->nextAction;
})
->wrap(),
Tables\Columns\TextColumn::make('policy_type') Tables\Columns\TextColumn::make('policy_type')
->label('Type') ->label('Type')
->badge() ->badge()
@ -329,8 +270,7 @@ public function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
Tables\Columns\TextColumn::make('policy_identifier') Tables\Columns\TextColumn::make('policy_identifier')
->label('Policy ID') ->label('Policy ID')
->copyable() ->copyable(),
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('platform') Tables\Columns\TextColumn::make('platform')
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
@ -372,30 +312,30 @@ public function table(Table $table): Table
} }
return '—'; return '—';
}) }),
->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('captured_at')->dateTime(),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at')->since(),
Tables\Columns\TextColumn::make('created_at')->since()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('policy_type')
->label('Type')
->options(FilterOptionCatalog::policyTypes())
->searchable(),
SelectFilter::make('restore_mode')
->label('Restore')
->options(static::restoreModeOptions())
->query(fn (Builder $query, array $data): Builder => static::applyRestoreModeFilter($query, $data['value'] ?? null)),
SelectFilter::make('platform')
->options(FilterOptionCatalog::platforms())
->searchable(),
]) ])
->filters([])
->headerActions([ ->headerActions([
$refreshTable, $refreshTable,
$addPolicies, $addPolicies,
]) ])
->actions([ ->actions([
Actions\ActionGroup::make([ Actions\ActionGroup::make([
Actions\ViewAction::make()
->label('View policy')
->url(function (BackupItem $record): ?string {
if (! $record->policy_id) {
return null;
}
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
})
->hidden(fn (BackupItem $record) => ! $record->policy_id)
->openUrlInNewTab(true),
$removeItem, $removeItem,
]) ])
->label('More') ->label('More')
@ -406,11 +346,6 @@ public function table(Table $table): Table
Actions\BulkActionGroup::make([ Actions\BulkActionGroup::make([
$bulkRemove, $bulkRemove,
])->label('More'), ])->label('More'),
])
->emptyStateHeading('No policies in this backup set')
->emptyStateDescription('Add policies to capture versions and assignments inside this backup set.')
->emptyStateActions([
$addPolicies->name('addPoliciesEmpty'),
]); ]);
} }
@ -431,143 +366,4 @@ private static function typeMeta(?string $type): array
return collect($types) return collect($types)
->firstWhere('type', $type) ?? []; ->firstWhere('type', $type) ?? [];
} }
/**
* @return array<string, string>
*/
private static function restoreModeOptions(): array
{
return collect(InventoryPolicyTypeMeta::all())
->pluck('restore')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->unique()
->sort()
->mapWithKeys(fn (string $value): array => [
$value => BadgeRenderer::spec(BadgeDomain::PolicyRestoreMode, $value)->label,
])
->all();
}
private static function applyRestoreModeFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || trim($value) === '') {
return $query;
}
$types = collect(InventoryPolicyTypeMeta::all())
->filter(fn (array $meta): bool => ($meta['restore'] ?? null) === $value)
->pluck('type')
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(fn (string $type): string => trim($type))
->values()
->all();
if ($types === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn('policy_type', $types);
}
private function backupItemInspectUrl(BackupItem $record): ?string
{
$backupSet = $this->getOwnerRecord();
$resolvedId = $this->resolveOwnerScopedBackupItemId($backupSet, $record);
$resolvedRecord = $backupSet->items()
->with(['policy', 'policyVersion', 'policyVersion.policy'])
->where('tenant_id', (int) $backupSet->tenant_id)
->whereKey($resolvedId)
->first();
if (! $resolvedRecord instanceof BackupItem) {
abort(404);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
if ($resolvedRecord->policy_version_id) {
return PolicyVersionResource::getUrl('view', ['record' => $resolvedRecord->policy_version_id], tenant: $tenant);
}
if (! $resolvedRecord->policy_id) {
return null;
}
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
}
private function backupItemQualitySummary(BackupItem $record): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forBackupItem($record);
}
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
{
$recordId = $this->normalizeBackupItemKey($record);
if ($recordId <= 0) {
abort(404);
}
$resolvedId = $backupSet->items()
->where('tenant_id', (int) $backupSet->tenant_id)
->whereKey($recordId)
->value('id');
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
abort(404);
}
return (int) $resolvedId;
}
/**
* @return array<int, int>
*/
private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, array $recordKeys): array
{
$requestedIds = collect($recordKeys)
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($requestedIds === []) {
return [];
}
$resolvedIds = $backupSet->items()
->where('tenant_id', (int) $backupSet->tenant_id)
->whereIn('id', $requestedIds)
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if (count($resolvedIds) !== count($requestedIds)) {
abort(404);
}
return $resolvedIds;
}
private function normalizeBackupItemKey(mixed $record): int
{
if ($record instanceof BackupItem) {
return (int) $record->getKey();
}
return is_numeric($record) ? (int) $record : 0;
}
} }

View File

@ -0,0 +1,246 @@
<?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',
};
}
}

View File

@ -9,55 +9,32 @@
use App\Services\Directory\EntraGroupSelection; use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListEntraGroups extends ListRecords class ListEntraGroups extends ListRecords
{ {
protected static string $resource = EntraGroupResource::class; protected static string $resource = EntraGroupResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
if (
Filament::getCurrentPanel()?->getId() === 'admin'
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
) {
abort(404);
}
parent::mount();
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$tenant = EntraGroupResource::panelTenantContext();
return [ return [
Action::make('view_operations') Action::make('view_operations')
->label('Operations') ->label('Operations')
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->url(fn (): string => OperationRunLinks::index($tenant)) ->url(fn (): string => OperationRunLinks::index(Tenant::current()))
->visible(fn (): bool => $tenant instanceof Tenant), ->visible(fn (): bool => (bool) Tenant::current()),
UiEnforcement::forAction( UiEnforcement::forAction(
Action::make('sync_groups') Action::make('sync_groups')
->label('Sync Groups') ->label('Sync Groups')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('warning')
->action(function (): void { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
$tenant = EntraGroupResource::panelTenantContext(); $tenant = Tenant::current();
if (! $user instanceof User || ! $tenant instanceof Tenant) { if (! $user instanceof User || ! $tenant instanceof Tenant) {
return; return;
@ -80,11 +57,13 @@ protected function getHeaderActions(): array
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('Open operation') ->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -101,13 +80,16 @@ protected function getHeaderActions(): array
operationRun: $opRun operationRun: $opRun
)); ));
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
OperationUxPresenter::queuedToast((string) $opRun->type) ->title('Group sync started')
->body('Sync dispatched.')
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('Open operation') ->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->sendToDatabase($user)
->send(); ->send();
}) })
) )

View File

@ -0,0 +1,11 @@
<?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;
}

View File

@ -0,0 +1,464 @@
<?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}'),
];
}
}

View File

@ -0,0 +1,153 @@
<?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;
}
}

View File

@ -0,0 +1,11 @@
<?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;
}

View File

@ -3,26 +3,25 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource\Pages; use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Enums\RelationshipType;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -36,9 +35,6 @@
class InventoryItemResource extends Resource class InventoryItemResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = InventoryItem::class; protected static ?string $model = InventoryItem::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -51,15 +47,6 @@ class InventoryItemResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
@ -72,7 +59,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -87,7 +74,7 @@ public static function canViewAny(): bool
public static function canView(Model $record): bool public static function canView(Model $record): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -176,6 +163,29 @@ public static function infolist(Schema $schema): Schema
ViewEntry::make('dependencies') ViewEntry::make('dependencies')
->label('') ->label('')
->view('filament.components.dependency-edges') ->view('filament.components.dependency-edges')
->state(function (InventoryItem $record) {
$direction = request()->query('direction', 'all');
$relationshipType = request()->query('relationship_type', 'all');
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
$relationshipType = $relationshipType === 'all'
? null
: RelationshipType::tryFrom($relationshipType)?->value;
$service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class);
$tenant = Tenant::current();
$edges = collect();
if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
}
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
})
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
@ -194,7 +204,10 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
$typeOptions = FilterOptionCatalog::policyTypes(); $typeOptions = collect(static::allTypeMeta())
->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)])
->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '')
->all();
$categoryOptions = collect(static::allTypeMeta()) $categoryOptions = collect(static::allTypeMeta())
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)]) ->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
@ -203,15 +216,10 @@ public static function table(Table $table): Table
return $table return $table
->defaultSort('last_seen_at', 'desc') ->defaultSort('last_seen_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([ ->columns([
Tables\Columns\TextColumn::make('display_name') Tables\Columns\TextColumn::make('display_name')
->label('Name') ->label('Name')
->searchable() ->searchable(),
->sortable(),
Tables\Columns\TextColumn::make('policy_type') Tables\Columns\TextColumn::make('policy_type')
->label('Type') ->label('Type')
->badge() ->badge()
@ -269,54 +277,21 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('category') Tables\Filters\SelectFilter::make('category')
->options($categoryOptions) ->options($categoryOptions)
->searchable(), ->searchable(),
Tables\Filters\SelectFilter::make('platform')
->options(FilterOptionCatalog::platforms())
->searchable(),
Tables\Filters\SelectFilter::make('stale')
->label('Freshness')
->options([
'0' => 'Fresh',
'1' => 'Stale',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if ($value === null || $value === '') {
return $query;
}
$cutoff = now()->subHours(max(1, (int) config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24)));
if ((string) $value === '1') {
return $query->where(function (Builder $staleQuery) use ($cutoff): void {
$staleQuery
->whereNull('last_seen_at')
->orWhere('last_seen_at', '<', $cutoff);
});
}
return $query->where('last_seen_at', '>=', $cutoff);
}),
]) ])
->recordUrl(static fn (Model $record): ?string => static::canView($record) ->recordUrl(static fn (Model $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record]) ? static::getUrl('view', ['record' => $record])
: null) : null)
->actions([]) ->actions([])
->bulkActions([]) ->bulkActions([]);
->emptyStateHeading('No inventory items')
->emptyStateDescription('Run an inventory sync to capture policy state for this tenant.')
->emptyStateIcon('heroicon-o-clipboard-document-list');
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
return static::getTenantOwnedEloquentQuery() $tenantId = Tenant::current()?->getKey();
->with('lastSeenRun');
}
public static function resolveScopedRecordOrFail(int|string $key): Model return parent::getEloquentQuery()
{ ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun')); ->with('lastSeenRun');
} }
public static function getPages(): array public static function getPages(): array

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources\InventoryItemResource\Pages; namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Jobs\RunInventorySyncJob; use App\Jobs\RunInventorySyncJob;
@ -12,7 +11,6 @@
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -30,21 +28,8 @@
class ListInventoryItems extends ListRecords class ListInventoryItems extends ListRecords
{ {
use ResolvesPanelTenantContext;
protected static string $resource = InventoryItemResource::class; protected static string $resource = InventoryItemResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
{ {
return [ return [
@ -59,7 +44,7 @@ protected function getHeaderActions(): array
Action::make('run_inventory_sync') Action::make('run_inventory_sync')
->label('Run Inventory Sync') ->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('warning')
->form([ ->form([
Select::make('policy_types') Select::make('policy_types')
->label('Policy types') ->label('Policy types')
@ -105,7 +90,7 @@ protected function getHeaderActions(): array
->columnSpanFull(), ->columnSpanFull(),
Toggle::make('include_foundations') Toggle::make('include_foundations')
->label('Include foundation types') ->label('Include foundation types')
->helperText('Include scope tags, assignment filters, notification templates, and Intune RBAC role definitions and assignments.') ->helperText('Include scope tags, assignment filters, and notification templates.')
->default(true) ->default(true)
->dehydrated() ->dehydrated()
->rules(['boolean']) ->rules(['boolean'])
@ -118,7 +103,7 @@ protected function getHeaderActions(): array
->rules(['boolean']) ->rules(['boolean'])
->columnSpanFull(), ->columnSpanFull(),
Hidden::make('tenant_id') Hidden::make('tenant_id')
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey()) ->default(fn (): ?string => Tenant::current()?->getKey())
->dehydrated(), ->dehydrated(),
]) ])
->visible(function (): bool { ->visible(function (): bool {
@ -127,7 +112,7 @@ protected function getHeaderActions(): array
return false; return false;
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return false; return false;
} }
@ -135,7 +120,7 @@ protected function getHeaderActions(): array
return $user->canAccessTenant($tenant); return $user->canAccessTenant($tenant);
}) })
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -174,8 +159,6 @@ protected function getHeaderActions(): array
], ],
context: array_merge($computed['selection'], [ context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'], 'selection_hash' => $computed['selection_hash'],
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'target_scope' => [ 'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(), 'entra_tenant_id' => $tenant->graphTenantId(),
], ],
@ -184,10 +167,13 @@ protected function getHeaderActions(): array
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) Notification::make()
->title('Inventory sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('Open operation') ->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -224,7 +210,7 @@ protected function getHeaderActions(): array
OperationUxPresenter::queuedToast((string) $opRun->type) OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();

View File

@ -4,14 +4,8 @@
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewInventoryItem extends ViewRecord class ViewInventoryItem extends ViewRecord
{ {
protected static string $resource = InventoryItemResource::class; protected static string $resource = InventoryItemResource::class;
protected function resolveRecord(int|string $key): Model
{
return InventoryItemResource::resolveScopedRecordOrFail($key);
}
} }

View File

@ -0,0 +1,482 @@
<?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;
}
}

View File

@ -0,0 +1,102 @@
<?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();
}
}

View File

@ -16,7 +16,6 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width; use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ViewPolicy extends ViewRecord class ViewPolicy extends ViewRecord
@ -25,11 +24,6 @@ class ViewPolicy extends ViewRecord
protected Width|string|null $maxContentWidth = Width::Full; protected Width|string|null $maxContentWidth = Width::Full;
protected function resolveRecord(int|string $key): Model
{
return PolicyResource::resolveScopedRecordOrFail($key);
}
protected function getActions(): array protected function getActions(): array
{ {
return [$this->makeCaptureSnapshotAction()]; return [$this->makeCaptureSnapshotAction()];
@ -112,7 +106,7 @@ private function makeCaptureSnapshotAction(): Action
->body('An active run already exists for this policy. Opening run details.') ->body('An active run already exists for this policy. Opening run details.')
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->info() ->info()
@ -145,7 +139,7 @@ private function makeCaptureSnapshotAction(): Action
OperationUxPresenter::queuedToast('policy.capture_snapshot') OperationUxPresenter::queuedToast('policy.capture_snapshot')
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();

View File

@ -2,10 +2,7 @@
namespace App\Filament\Resources\PolicyResource\RelationManagers; namespace App\Filament\Resources\PolicyResource\RelationManagers;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -29,29 +26,14 @@
class VersionsRelationManager extends RelationManager class VersionsRelationManager extends RelationManager
{ {
use ResolvesPanelTenantContext;
protected static string $relationship = 'versions'; protected static string $relationship = 'versions';
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && $name === 'restore_to_intune' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $context['recordKey']);
}
return parent::mountAction($name, $arguments, $context);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.') ->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while restore remains the only inline row shortcut.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.'); ->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
} }
@ -70,9 +52,8 @@ public function table(Table $table): Table
->label('Preview only (dry-run)') ->label('Preview only (dry-run)')
->default(true), ->default(true),
]) ])
->action(function (mixed $record, array $data, RestoreService $restoreService) { ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record); $tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -129,7 +110,7 @@ public function table(Table $table): Table
return true; return true;
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -149,7 +130,7 @@ public function table(Table $table): Table
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -181,37 +162,14 @@ public function table(Table $table): Table
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->defaultSort('version_number', 'desc') ->defaultSort('version_number', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->recordUrl(fn (PolicyVersion $record): string => PolicyVersionResource::getUrl('view', ['record' => $record]))
->filters([]) ->filters([])
->headerActions([]) ->headerActions([])
->actions([ ->actions([
$restoreToIntune, $restoreToIntune,
Actions\ViewAction::make()
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
]) ])
->bulkActions([]) ->bulkActions([]);
->emptyStateHeading('No versions captured')
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
}
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
{
$recordId = $record instanceof PolicyVersion
? (int) $record->getKey()
: (is_numeric($record) ? (int) $record : 0);
if ($recordId <= 0) {
abort(404);
}
$resolvedRecord = $policy->versions()
->where('tenant_id', (int) $policy->tenant_id)
->whereKey($recordId)
->first();
if (! $resolvedRecord instanceof PolicyVersion) {
abort(404);
}
return $resolvedRecord;
} }
} }

View File

@ -2,12 +2,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\PolicyVersionResource\Pages; use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Filament\Support\NormalizedDiffSurface;
use App\Filament\Support\NormalizedSettingsSurface;
use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionPruneJob;
use App\Jobs\BulkPolicyVersionRestoreJob; use App\Jobs\BulkPolicyVersionRestoreJob;
@ -23,21 +18,11 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\RedactionIntegrity;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -47,12 +32,10 @@
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@ -66,32 +49,17 @@
class PolicyVersionResource extends Resource class PolicyVersionResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static ?string $model = PolicyVersion::class; protected static ?string $model = PolicyVersion::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -112,7 +80,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA routes operators to backup sets when no versions are available yet.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; versions appear after policy sync/capture workflows.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.'); ->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.');
} }
@ -120,9 +88,7 @@ public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Infolists\Components\TextEntry::make('policy.display_name') Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
->label('Policy')
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
Infolists\Components\TextEntry::make('version_number')->label('Version'), Infolists\Components\TextEntry::make('version_number')->label('Version'),
Infolists\Components\TextEntry::make('policy_type') Infolists\Components\TextEntry::make('policy_type')
->badge() ->badge()
@ -134,46 +100,6 @@ public static function infolist(Schema $schema): Schema
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Infolists\Components\TextEntry::make('created_by')->label('Actor'), Infolists\Components\TextEntry::make('created_by')->label('Actor'),
Infolists\Components\TextEntry::make('captured_at')->dateTime(), Infolists\Components\TextEntry::make('captured_at')->dateTime(),
Section::make('Backup quality')
->schema([
Infolists\Components\TextEntry::make('quality_snapshot_mode')
->label('Snapshot')
->badge()
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Infolists\Components\TextEntry::make('quality_summary')
->label('Backup quality')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
Infolists\Components\TextEntry::make('quality_assignment_signal')
->label('Assignment quality')
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
Infolists\Components\TextEntry::make('quality_next_action')
->label('Next action')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
Infolists\Components\TextEntry::make('quality_integrity_warning')
->label('Integrity note')
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
->columnSpanFull(),
Infolists\Components\TextEntry::make('quality_boundary')
->label('Boundary')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Related context')
->schema([
Infolists\Components\ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (PolicyVersion $record): array => static::relatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Tabs::make() Tabs::make()
->activeTab(1) ->activeTab(1)
->persistTabInQueryString('tab') ->persistTabInQueryString('tab')
@ -182,7 +108,7 @@ public static function infolist(Schema $schema): Schema
Tab::make('Normalized settings') Tab::make('Normalized settings')
->id('normalized-settings') ->id('normalized-settings')
->schema([ ->schema([
Infolists\Components\ViewEntry::make('normalized_settings') Infolists\Components\ViewEntry::make('normalized_settings_catalog')
->view('filament.infolists.entries.normalized-settings') ->view('filament.infolists.entries.normalized-settings')
->state(function (PolicyVersion $record) { ->state(function (PolicyVersion $record) {
$normalized = app(PolicyNormalizer::class)->normalize( $normalized = app(PolicyNormalizer::class)->normalize(
@ -191,12 +117,29 @@ public static function infolist(Schema $schema): Schema
$record->platform $record->platform
); );
$normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey();
return $normalized;
})
->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
Infolists\Components\ViewEntry::make('normalized_settings_standard')
->view('filament.infolists.entries.policy-settings-standard')
->state(function (PolicyVersion $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
is_array($record->snapshot) ? $record->snapshot : [],
$record->policy_type ?? '',
$record->platform
);
$normalized['context'] = 'version'; $normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey(); $normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type; $normalized['policy_type'] = $record->policy_type;
return NormalizedSettingsSurface::build($normalized, 'policy_version'); return $normalized;
}), })
->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
]), ]),
Tab::make('Raw JSON') Tab::make('Raw JSON')
->id('raw-json') ->id('raw-json')
@ -223,7 +166,7 @@ public static function infolist(Schema $schema): Schema
$result = $diff->compare($from, $to); $result = $diff->compare($from, $to);
$result['policy_type'] = $record->policy_type; $result['policy_type'] = $record->policy_type;
return NormalizedDiffSurface::build($result, 'policy_version'); return $result;
}), }),
Infolists\Components\ViewEntry::make('diff_json') Infolists\Components\ViewEntry::make('diff_json')
->label('Raw diff (advanced)') ->label('Raw diff (advanced)')
@ -312,7 +255,7 @@ public static function table(Table $table): Table
return $fields; return $fields;
}) })
->action(function (Collection $records, array $data) { ->action(function (Collection $records, array $data) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -360,10 +303,24 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
Notification::make()
->title('Policy version prune queued')
->body("Queued prune for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
OperationUxPresenter::queuedToast('policy_version.prune') OperationUxPresenter::queuedToast('policy_version.prune')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -391,7 +348,7 @@ public static function table(Table $table): Table
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -438,7 +395,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('policy_version.restore') OperationUxPresenter::queuedToast('policy_version.restore')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -475,7 +432,7 @@ public static function table(Table $table): Table
]), ]),
]) ])
->action(function (Collection $records, array $data) { ->action(function (Collection $records, array $data) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -519,10 +476,24 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
Notification::make()
->title('Policy version force delete queued')
->body("Queued force delete for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
OperationUxPresenter::queuedToast('policy_version.force_delete') OperationUxPresenter::queuedToast('policy_version.force_delete')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
@ -535,31 +506,9 @@ public static function table(Table $table): Table
->apply(); ->apply();
return $table return $table
->defaultSort('captured_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([ ->columns([
Tables\Columns\TextColumn::make('policy.display_name') Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
->label('Policy')
->sortable()
->searchable()
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
Tables\Columns\TextColumn::make('version_number')->sortable(), Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('snapshot_mode')
->label('Snapshot')
->badge()
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Tables\Columns\TextColumn::make('backup_quality')
->label('Backup quality')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
->wrap(),
Tables\Columns\TextColumn::make('policy_type') Tables\Columns\TextColumn::make('policy_type')
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
@ -568,25 +517,20 @@ public static function table(Table $table): Table
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('policy_type') TrashedFilter::make()
->label('Type') ->label('Archived')
->options(FilterOptionCatalog::policyTypes()) ->placeholder('Active')
->searchable(), ->trueLabel('All')
Tables\Filters\SelectFilter::make('platform') ->falseLabel('Archived'),
->options(FilterOptionCatalog::platforms())
->searchable(),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
FilterPresets::archived(),
]) ])
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record) ->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record]) ? static::getUrl('view', ['record' => $record])
: null) : null)
->actions([ ->actions([
static::primaryRelatedAction(),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
(function (): Actions\Action { (function (): Actions\Action {
$action = Actions\Action::make('restore_via_wizard') $action = Actions\Action::make('restore_via_wizard')
@ -597,7 +541,7 @@ public static function table(Table $table): Table
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->visible(function (): bool { ->visible(function (): bool {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -610,11 +554,11 @@ public static function table(Table $table): Table
return $resolver->isMember($user, $tenant); return $resolver->isMember($user, $tenant);
}) })
->disabled(function (PolicyVersion $record): bool { ->disabled(function (PolicyVersion $record): bool {
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') { if (($record->metadata['source'] ?? null) === 'metadata_only') {
return true; return true;
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -631,7 +575,7 @@ public static function table(Table $table): Table
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
}) })
->tooltip(function (PolicyVersion $record): ?string { ->tooltip(function (PolicyVersion $record): ?string {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -649,14 +593,14 @@ public static function table(Table $table): Table
return 'You do not have permission to create restore runs.'; return 'You do not have permission to create restore runs.';
} }
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') { if (($record->metadata['source'] ?? null) === 'metadata_only') {
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
} }
return null; return null;
}) })
->action(function (PolicyVersion $record) { ->action(function (PolicyVersion $record) {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -674,7 +618,7 @@ public static function table(Table $table): Table
abort(403); abort(403);
} }
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') { if (($record->metadata['source'] ?? null) === 'metadata_only') {
Notification::make() Notification::make()
->title('Restore disabled for metadata-only snapshot') ->title('Restore disabled for metadata-only snapshot')
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.') ->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
@ -720,8 +664,6 @@ public static function table(Table $table): Table
'policy_version_id' => $record->id, 'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number, 'policy_version_number' => $record->version_number,
'policy_id' => $policy->id, 'policy_id' => $policy->id,
'redaction_version' => $record->redaction_version,
'integrity_warning' => RedactionIntegrity::noteForPolicyVersion($record),
], ],
]); ]);
@ -731,30 +673,12 @@ public static function table(Table $table): Table
$backupItemMetadata = [ $backupItemMetadata = [
'source' => 'policy_version', 'source' => 'policy_version',
'snapshot_source' => $record->snapshotSource(),
'display_name' => $policy->display_name, 'display_name' => $policy->display_name,
'policy_version_id' => $record->id, 'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number, 'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(), 'version_captured_at' => $record->captured_at?->toIso8601String(),
'redaction_version' => $record->redaction_version,
'warnings' => $record->warningMessages(),
'assignments_fetch_failed' => $record->assignmentsFetchFailed(),
'has_orphaned_assignments' => $record->hasOrphanedAssignments(),
]; ];
$integrityWarning = RedactionIntegrity::noteForPolicyVersion($record);
if ($integrityWarning !== null) {
$backupItemMetadata['integrity_warning'] = $integrityWarning;
}
$secretFingerprints = is_array($record->secret_fingerprints) ? $record->secret_fingerprints : [];
$protectedPathsCount = RedactionIntegrity::fingerprintCount($secretFingerprints);
if ($protectedPathsCount > 0) {
$backupItemMetadata['protected_paths_count'] = $protectedPathsCount;
}
if (is_array($scopeTagIds) && $scopeTagIds !== []) { if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds; $backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
} }
@ -924,127 +848,18 @@ public static function table(Table $table): Table
$bulkRestoreVersions, $bulkRestoreVersions,
$bulkForceDeleteVersions, $bulkForceDeleteVersions,
])->label('More'), ])->label('More'),
])
->emptyStateHeading('No policy versions')
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
->emptyStateIcon('heroicon-o-clock')
->emptyStateActions([
Actions\Action::make('open_backup_sets')
->label('Open backup sets')
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
->color('gray'),
]); ]);
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenant = static::resolveTenantContextForCurrentPanelOrFail(); $tenantId = Tenant::currentOrFail()->getKey();
$user = auth()->user();
$resolver = app(CapabilityResolver::class); return parent::getEloquentQuery()
$canSeeBaselinePurposeEvidence = $user instanceof User ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
&& (
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
);
return static::getTenantOwnedEloquentQuery()
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
return $query->where(function (Builder $query): void {
$query
->whereNull('capture_purpose')
->orWhereNotIn('capture_purpose', [
PolicyVersionCapturePurpose::BaselineCapture->value,
PolicyVersionCapturePurpose::BaselineCompare->value,
]);
});
})
->with('policy'); ->with('policy');
} }
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
$user = auth()->user();
$resolver = app(CapabilityResolver::class);
$canSeeBaselinePurposeEvidence = $user instanceof User
&& (
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
);
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()
->withTrashed()
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
return $query->where(function (Builder $query): void {
$query
->whereNull('capture_purpose')
->orWhereNotIn('capture_purpose', [
PolicyVersionCapturePurpose::BaselineCapture->value,
PolicyVersionCapturePurpose::BaselineCompare->value,
]);
});
})
->with('policy'),
);
}
/**
* @return list<array{
* key: string,
* label: string,
* value: string,
* secondaryValue: ?string,
* targetUrl: ?string,
* targetKind: string,
* availability: string,
* unavailableReason: ?string,
* contextBadge: ?string,
* priority: int,
* actionLabel: string
* }>
*/
public static function relatedContextEntries(PolicyVersion $record): array
{
return app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $record);
}
private static function primaryRelatedAction(): Actions\Action
{
return Actions\Action::make('primary_drill_down')
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
->color('gray');
}
private static function policyVersionQualitySummary(PolicyVersion $record): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forPolicyVersion($record);
}
private static function policyVersionAssignmentQualityLabel(PolicyVersion $record): string
{
$summary = static::policyVersionQualitySummary($record);
return match (true) {
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
default => 'No assignment issues were detected from captured metadata.',
};
}
private static function primaryRelatedEntry(PolicyVersion $record): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $record);
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
@ -1052,19 +867,4 @@ public static function getPages(): array
'view' => Pages\ViewPolicyVersion::route('/{record}'), 'view' => Pages\ViewPolicyVersion::route('/{record}'),
]; ];
} }
private static function resolvedDisplayName(PolicyVersion $record): string
{
$snapshot = is_array($record->snapshot) ? $record->snapshot : [];
$displayName = $snapshot['displayName']
?? $snapshot['name']
?? $record->policy?->display_name
?? null;
if (is_string($displayName) && $displayName !== '') {
return $displayName;
}
return sprintf('Version %d', (int) $record->version_number);
}
} }

View File

@ -3,24 +3,12 @@
namespace App\Filament\Resources\PolicyVersionResource\Pages; namespace App\Filament\Resources\PolicyVersionResource\Pages;
use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListPolicyVersions extends ListRecords class ListPolicyVersions extends ListRecords
{ {
protected static string $resource = PolicyVersionResource::class; protected static string $resource = PolicyVersionResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [];

View File

@ -0,0 +1,22 @@
<?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

View File

@ -6,10 +6,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
@ -35,18 +31,6 @@ protected function mutateFormDataBeforeCreate(array $data): array
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => $data['entra_tenant_id'], 'entra_tenant_id' => $data['entra_tenant_id'],
'display_name' => $data['display_name'], 'display_name' => $data['display_name'],
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value,
'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null,
'consent_last_checked_at' => null,
'consent_error_code' => null,
'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value,
'migration_review_required' => false,
'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'last_error_message' => null,
'is_default' => false, 'is_default' => false,
]; ];
} }
@ -73,7 +57,6 @@ protected function afterCreate(): void
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'connection_type' => $record->connection_type->value,
], ],
], ],
actorId: $actorId, actorId: $actorId,

View File

@ -0,0 +1,878 @@
<?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();
}
}

View File

@ -7,26 +7,14 @@
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListProviderConnections extends ListRecords class ListProviderConnections extends ListRecords
{ {
protected static string $resource = ProviderConnectionResource::class; protected static string $resource = ProviderConnectionResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: 'tenant',
tenantAttribute: 'external_id',
);
parent::mount();
}
private function tableHasRecords(): bool private function tableHasRecords(): bool
{ {
return $this->getTableRecords()->count() > 0; return $this->getTableRecords()->count() > 0;
@ -219,7 +207,9 @@ private function resolveTenantExternalIdForCreateAction(): ?string
return $requested; return $requested;
} }
return ProviderConnectionResource::resolveContextTenantExternalId(); $filamentTenant = Filament::getTenant();
return $filamentTenant instanceof Tenant ? (string) $filamentTenant->external_id : null;
} }
private function resolveTenantForCreateAction(): ?Tenant private function resolveTenantForCreateAction(): ?Tenant
@ -237,12 +227,12 @@ private function resolveTenantForCreateAction(): ?Tenant
public function getTableEmptyStateHeading(): ?string public function getTableEmptyStateHeading(): ?string
{ {
return 'No Microsoft connections found'; return 'No provider connections found';
} }
public function getTableEmptyStateDescription(): ?string public function getTableEmptyStateDescription(): ?string
{ {
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.'; return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.';
} }
public function getTableEmptyStateActions(): array public function getTableEmptyStateActions(): array

View File

@ -0,0 +1,28 @@
<?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(),
];
}
}

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources\RestoreRunResource\Pages; namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Tenant; use App\Models\Tenant;
@ -18,13 +17,12 @@
class CreateRestoreRun extends CreateRecord class CreateRestoreRun extends CreateRecord
{ {
use HasWizard; use HasWizard;
use ResolvesPanelTenantContext;
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::class;
protected function authorizeAccess(): void protected function authorizeAccess(): void
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -62,7 +60,7 @@ protected function afterFill(): void
return; return;
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = Tenant::current();
if (! $tenant) { if (! $tenant) {
return; return;
@ -96,9 +94,6 @@ protected function afterFill(): void
$this->form->callAfterStateUpdated('data.backup_item_ids'); $this->form->callAfterStateUpdated('data.backup_item_ids');
} }
$this->data = RestoreRunResource::synchronizeRestoreSafetyDraft($this->data);
$this->form->fill($this->data);
} }
/** /**
@ -154,10 +149,13 @@ protected function handleRecordCreation(array $data): Model
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
{ {
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId); data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
$this->data['is_dry_run'] = true;
$this->data['acknowledged_impact'] = false; $this->data['check_summary'] = null;
$this->data['tenant_confirm'] = null; $this->data['check_results'] = [];
$this->data = RestoreRunResource::synchronizeRestoreSafetyDraft($this->data); $this->data['checks_ran_at'] = null;
$this->data['preview_summary'] = null;
$this->data['preview_diffs'] = [];
$this->data['preview_ran_at'] = null;
$this->form->fill($this->data); $this->form->fill($this->data);

View File

@ -0,0 +1,43 @@
<?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,
];
}
}

View File

@ -0,0 +1,11 @@
<?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

View File

@ -0,0 +1,41 @@
<?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'],
]);
}
}

View File

@ -0,0 +1,38 @@
<?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(),
];
}
}

View File

@ -0,0 +1,31 @@
<?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.'),
];
}
}

Some files were not shown because too many files have changed in this diff Show More