Compare commits
80 Commits
feat/031-t
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e34b6084f | |||
| 439248ba15 | |||
| b6343d5c3a | |||
| 5f9e6fb04a | |||
| 38d9826f5e | |||
| a989ef1a23 | |||
| 3490fb9e2c | |||
| d1a9989037 | |||
| 7217559e5a | |||
| 6a86c5901a | |||
| cfbc74c035 | |||
| d90fb0f963 | |||
| 3a3de045ba | |||
| 210cf5ce8b | |||
| c5fbcaa692 | |||
| 81c010fa00 | |||
| eef85af990 | |||
| a0ed9e24c5 | |||
| 1bc6600fcc | |||
| 0b6600b926 | |||
| e1ed7ae232 | |||
| ec9f28ccbd | |||
| abda751296 | |||
| 5745461654 | |||
|
|
c41f264231 | ||
|
|
6a2fe91547 | ||
|
|
8b17bbe9be | ||
|
|
283daeab33 | ||
|
|
9870f5d102 | ||
| 971105daa9 | |||
|
|
9a8b283b1d | ||
|
|
aa8d132f3c | ||
|
|
a6ab36aca4 | ||
|
|
e442a865d6 | ||
|
|
eac19118a2 | ||
| a97beefda3 | |||
|
|
ec99c6519c | ||
|
|
1ed3b953da | ||
|
|
6737ba7d85 | ||
|
|
b807a7bb96 | ||
|
|
cb3da561ef | ||
|
|
c352bc9a17 | ||
|
|
7b96ef8dd8 | ||
|
|
5118497da9 | ||
|
|
bcdeeb5525 | ||
| bd6df1f343 | |||
|
|
af17875b9d | ||
|
|
45f40d0a08 | ||
|
|
37873829fd | ||
|
|
b62d7c2ca5 | ||
|
|
8b9ab52138 | ||
| 3030dd9af2 | |||
|
|
39287b250b | ||
|
|
94f8719e09 | ||
|
|
9f980ce80e | ||
|
|
48b558db93 | ||
| 30ad57baab | |||
| c60d16ffba | |||
| a449ecec5b | |||
| bc846d7c5c | |||
| bcf4996a1e | |||
| 060a82a1ed | |||
| b35e3a6518 | |||
| bbb1cb0982 | |||
| 9c56a2349a | |||
| da18d3cb14 | |||
| 361e301f67 | |||
| cf5b0027e3 | |||
| 16c9c7ee80 | |||
| 93dbd3b13d | |||
| 1340c47f54 | |||
| 8ae7a7234e | |||
| dedca3c612 | |||
| 3465076a04 | |||
| d63bce7b54 | |||
| 78467a76ac | |||
| a62c855851 | |||
| 4d3fcd28a9 | |||
| beffbfca4c | |||
| 2ca989c00f |
173
.ai/guidelines/filament-v5-blueprint.md
Normal file
173
.ai/guidelines/filament-v5-blueprint.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
## Source of Truth
|
||||||
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
|
- docs/research/filament-v5-notes.md
|
||||||
|
and prefer that over guesses.
|
||||||
|
|
||||||
|
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||||
|
|
||||||
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
|
## 1) Non-negotiables
|
||||||
|
- Filament v5 requires Livewire v4.0+.
|
||||||
|
- 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.
|
||||||
|
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||||
|
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
|
## 2) Directory & naming conventions
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
|
## 3) Panel setup defaults
|
||||||
|
- 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.
|
||||||
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
|
- Assets policy:
|
||||||
|
- Panel-only assets: register via panel config.
|
||||||
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
|
- Deployment must include `php artisan filament:assets`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 4) Navigation & information architecture
|
||||||
|
- 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.
|
||||||
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
|
- User menu:
|
||||||
|
- Configure via `userMenuItems()` with Action objects.
|
||||||
|
- Never put destructive actions there without confirmation + authorization.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
|
## 5) Resource patterns
|
||||||
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
|
- Global search:
|
||||||
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
|
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||||
|
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||||
|
- For very large datasets: consider disabling term splitting (only when needed).
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
|
## 6) Page lifecycle & query rules
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
|
- Inline CRUD inside owner form → Repeater.
|
||||||
|
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||||
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
- 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).
|
||||||
|
- Custom field views must obey state binding modifiers.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
|
## 9) Table & action patterns
|
||||||
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
|
- Actions:
|
||||||
|
- Execution actions use `->action(...)`.
|
||||||
|
- Destructive actions add `->requiresConfirmation()`.
|
||||||
|
- Navigation-only actions should use `->url(...)`.
|
||||||
|
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
|
## 10) Authorization & security
|
||||||
|
- Enforce panel access in non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/users/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
|
## 11) Notifications & UX feedback
|
||||||
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
|
## 12) Performance defaults
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 13) Testing requirements
|
||||||
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
|
- Test actions using Filament’s action testing guidance.
|
||||||
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
|
## 14) Forbidden patterns
|
||||||
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
|
- Destructive actions without `->requiresConfirmation()`.
|
||||||
|
- Shipping heavy assets globally when on-demand loading fits.
|
||||||
|
- Publishing Filament internal views as a default customization technique.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 15) Agent output contract
|
||||||
|
For any implementation request, the agent must explicitly state:
|
||||||
|
1) Livewire v4.0+ compliance.
|
||||||
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
|
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||||
|
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||||
|
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||||
|
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
79
.ai/guidelines/filament-v5-checklist.md
Normal file
79
.ai/guidelines/filament-v5-checklist.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
|
## Version Safety
|
||||||
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
|
- 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).
|
||||||
|
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
|
## Panel & Navigation
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||||
|
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||||
|
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||||
|
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||||
|
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
|
## Resource Structure
|
||||||
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
|
- 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.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||||
|
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
|
## Infolists & Relations
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Tables & Actions
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
|
## Authorization & Security
|
||||||
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
|
## UX & Notifications
|
||||||
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
|
- [ ] 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)”
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] 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?”
|
||||||
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
|
## Deployment / Ops
|
||||||
|
- [ ] `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”
|
||||||
@ -1,14 +1,22 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
|
coverage/
|
||||||
.git/
|
.git/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
public/storage/
|
||||||
|
storage/framework/
|
||||||
|
storage/logs/
|
||||||
storage/debugbar/
|
storage/debugbar/
|
||||||
storage/*.key
|
storage/*.key
|
||||||
/references/
|
/references/
|
||||||
|
|||||||
10
.env.example
10
.env.example
@ -63,3 +63,13 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# Entra ID (OIDC) - Tenant Admin (/admin) sign-in
|
||||||
|
ENTRA_CLIENT_ID=
|
||||||
|
ENTRA_CLIENT_SECRET=
|
||||||
|
ENTRA_REDIRECT_URI="${APP_URL}/auth/entra/callback"
|
||||||
|
ENTRA_AUTHORITY_TENANT=organizations
|
||||||
|
|
||||||
|
# System panel break-glass (Platform Operators)
|
||||||
|
BREAK_GLASS_ENABLED=false
|
||||||
|
BREAK_GLASS_TTL_MINUTES=60
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
{
|
{
|
||||||
"general": {
|
"general": {
|
||||||
"previewFeatures": false
|
"previewFeatures": false
|
||||||
}
|
},
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "vendor/bin/sail",
|
||||||
|
"args": [
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
16
.github/agents/copilot-instructions.md
vendored
16
.github/agents/copilot-instructions.md
vendored
@ -5,6 +5,17 @@ # TenantAtlas Development Guidelines
|
|||||||
## 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)
|
||||||
|
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
|
||||||
|
- PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 (feat/042-inventory-dependencies-graph)
|
||||||
|
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
|
||||||
|
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
|
||||||
|
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
||||||
|
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
|
||||||
|
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||||
|
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||||
|
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||||
|
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||||
|
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -24,9 +35,10 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
||||||
|
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
||||||
|
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
||||||
|
|
||||||
- feat/005-bulk-operations: Added PHP 8.4.15
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
669
.github/copilot-instructions.md
vendored
Normal file
669
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,669 @@
|
|||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== .ai/filament-v5-blueprint rules ===
|
||||||
|
|
||||||
|
## Source of Truth
|
||||||
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
|
- docs/research/filament-v5-notes.md
|
||||||
|
and prefer that over guesses.
|
||||||
|
|
||||||
|
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||||
|
|
||||||
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
|
## 1) Non-negotiables
|
||||||
|
- Filament v5 requires Livewire v4.0+.
|
||||||
|
- 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.
|
||||||
|
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||||
|
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
|
## 2) Directory & naming conventions
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
|
## 3) Panel setup defaults
|
||||||
|
- 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.
|
||||||
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
|
- Assets policy:
|
||||||
|
- Panel-only assets: register via panel config.
|
||||||
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
|
- Deployment must include `php artisan filament:assets`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 4) Navigation & information architecture
|
||||||
|
- 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.
|
||||||
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
|
- User menu:
|
||||||
|
- Configure via `userMenuItems()` with Action objects.
|
||||||
|
- Never put destructive actions there without confirmation + authorization.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
|
## 5) Resource patterns
|
||||||
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
|
- Global search:
|
||||||
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
|
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||||
|
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||||
|
- For very large datasets: consider disabling term splitting (only when needed).
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
|
## 6) Page lifecycle & query rules
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
|
- Inline CRUD inside owner form → Repeater.
|
||||||
|
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||||
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
- 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).
|
||||||
|
- Custom field views must obey state binding modifiers.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
|
## 9) Table & action patterns
|
||||||
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
|
- Actions:
|
||||||
|
- Execution actions use `->action(...)`.
|
||||||
|
- Destructive actions add `->requiresConfirmation()`.
|
||||||
|
- Navigation-only actions should use `->url(...)`.
|
||||||
|
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
|
## 10) Authorization & security
|
||||||
|
- Enforce panel access in non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/users/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
|
## 11) Notifications & UX feedback
|
||||||
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
|
## 12) Performance defaults
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 13) Testing requirements
|
||||||
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
|
- Test actions using Filament’s action testing guidance.
|
||||||
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
|
## 14) Forbidden patterns
|
||||||
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
|
- Destructive actions without `->requiresConfirmation()`.
|
||||||
|
- Shipping heavy assets globally when on-demand loading fits.
|
||||||
|
- Publishing Filament internal views as a default customization technique.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 15) Agent output contract
|
||||||
|
For any implementation request, the agent must explicitly state:
|
||||||
|
1) Livewire v4.0+ compliance.
|
||||||
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
|
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||||
|
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||||
|
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||||
|
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
|
=== .ai/filament-v5-checklist rules ===
|
||||||
|
|
||||||
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
|
## Version Safety
|
||||||
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
|
- 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).
|
||||||
|
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
|
## Panel & Navigation
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||||
|
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||||
|
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||||
|
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||||
|
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
|
## Resource Structure
|
||||||
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
|
- 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.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||||
|
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
|
## Infolists & Relations
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Tables & Actions
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
|
## Authorization & Security
|
||||||
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
|
## UX & Notifications
|
||||||
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
|
- [ ] 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)”
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] 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?”
|
||||||
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
|
## Deployment / Ops
|
||||||
|
- [ ] `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”
|
||||||
|
|
||||||
|
=== foundation rules ===
|
||||||
|
|
||||||
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- php - 8.4.15
|
||||||
|
- filament/filament (FILAMENT) - v5
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/socialite (SOCIALITE) - v5
|
||||||
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- pestphp/pest (PEST) - v4
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Application Structure & Architecture
|
||||||
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Frontend Bundling
|
||||||
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
- 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 ===
|
||||||
|
|
||||||
|
## Laravel Boost
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||||
|
- 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.
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
### 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'.
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
|
### Type Declarations
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
=== sail rules ===
|
||||||
|
|
||||||
|
## Laravel Sail
|
||||||
|
|
||||||
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
|
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||||
|
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||||
|
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||||
|
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||||
|
- Install Composer packages: `vendor/bin/sail composer install`
|
||||||
|
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||||
|
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||||
|
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
|
=== tests rules ===
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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 ===
|
||||||
|
|
||||||
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- 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.
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Controllers & Validation
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Queues
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
### 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')`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
|
## Laravel 12
|
||||||
|
|
||||||
|
- Use the `search-docs` tool to get version-specific documentation.
|
||||||
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
|
### Laravel 12 Structure
|
||||||
|
- 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()`.
|
||||||
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
|
### 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 ===
|
||||||
|
|
||||||
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- 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
|
||||||
|
### Testing
|
||||||
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
|
### Pest Tests
|
||||||
|
- 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.
|
||||||
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
|
- 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 ===
|
||||||
|
|
||||||
|
## Tailwind CSS
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||||
|
- 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>
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
@ -13,13 +14,21 @@
|
|||||||
/.zed
|
/.zed
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/storage/framework
|
||||||
|
/storage/logs
|
||||||
/vendor
|
/vendor
|
||||||
|
/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/references
|
/references
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
|||||||
@ -2,6 +2,7 @@ dist/
|
|||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
vendor/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@ -3,7 +3,11 @@ dist/
|
|||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
public/storage/
|
||||||
coverage/
|
coverage/
|
||||||
|
vendor/
|
||||||
|
storage/
|
||||||
|
bootstrap/cache/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
14
.specify/README.md
Normal file
14
.specify/README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# `.specify/` (Tooling)
|
||||||
|
|
||||||
|
This folder contains **SpecKit tooling** (templates, scripts, constitution, etc.).
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- **Do not** create new feature specs in `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md`.
|
||||||
|
- Active feature specs live under `specs/<NNN>-<slug>/`:
|
||||||
|
- `spec.md`
|
||||||
|
- `plan.md`
|
||||||
|
- `tasks.md`
|
||||||
|
- `checklists/requirements.md`
|
||||||
|
|
||||||
|
The files `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md` may exist as legacy references only.
|
||||||
@ -1,24 +1,154 @@
|
|||||||
|
<!--
|
||||||
|
Sync Impact Report
|
||||||
|
|
||||||
|
- Version change: 1.5.0 → 1.6.0
|
||||||
|
- Modified principles:
|
||||||
|
- Tenant Isolation is Non-negotiable (clarified 404 vs 403 semantics)
|
||||||
|
- RBAC guidance consolidated (RBAC model rules merged into RBAC-UX)
|
||||||
|
- Added sections:
|
||||||
|
- RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
|
- Removed sections: None (RBAC-001..009 content consolidated into RBAC-UX)
|
||||||
|
- Templates requiring updates:
|
||||||
|
- ✅ .specify/templates/plan-template.md
|
||||||
|
- ✅ .specify/templates/spec-template.md
|
||||||
|
- ✅ .specify/templates/tasks-template.md
|
||||||
|
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
||||||
|
- Follow-up TODOs: None
|
||||||
|
-->
|
||||||
|
|
||||||
# TenantPilot Constitution
|
# TenantPilot Constitution
|
||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
### Safety-First Restore
|
### Inventory-first, Snapshots-second
|
||||||
- Any destructive action MUST support preview/dry-run, explicit confirmation, and a clear pre-execution summary.
|
- All modules MUST operate primarily on Inventory as “last observed” state.
|
||||||
- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests + checklist.
|
- Inventory is the source of truth for what TenantPilot last observed; Microsoft Intune remains the external truth.
|
||||||
- Restore must be defensive: validate inputs, detect conflicts, allow selective restore, and record outcomes per item.
|
- Snapshots/Backups MUST be explicit actions (manual or scheduled) and MUST remain immutable.
|
||||||
|
|
||||||
### Auditability & Tenant Isolation
|
### Read/Write Separation by Default
|
||||||
- Every operation is tenant-scoped and MUST write an audit log entry (no secrets, no tokens).
|
- Analysis, reporting, and monitoring features MUST be read-only by default.
|
||||||
- Snapshots are immutable JSONB and MUST remain reproducible (who/when/what/source tenant).
|
- Any write/change function (restore, remediation, promotion) MUST include preview/dry-run, explicit confirmation, audit logging, and tests.
|
||||||
|
- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests.
|
||||||
|
|
||||||
### Graph Abstraction & Contracts
|
### Single Contract Path to Graph
|
||||||
- All Microsoft Graph calls MUST go through `GraphClientInterface`.
|
- All Microsoft Graph calls MUST go through `GraphClientInterface`.
|
||||||
- Contract assumptions are config-driven (`config/graph_contracts.php`); do not hardcode endpoints in feature code.
|
- Object types and endpoints MUST be modeled first in the contract registry (`config/graph_contracts.php`).
|
||||||
- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than calling `deviceManagement/{type}`.
|
- Feature code MUST NOT hardcode “quick endpoints” or bypass contracts.
|
||||||
|
- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than guessing endpoints.
|
||||||
|
|
||||||
### Least Privilege
|
### Deterministic Capabilities
|
||||||
|
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||||
|
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||||
|
|
||||||
|
### Tenant Isolation is Non-negotiable
|
||||||
|
- Every read/write MUST be tenant-scoped.
|
||||||
|
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
||||||
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
||||||
- Never store secrets in code/config; never log credentials or tokens.
|
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
|
||||||
|
deny-as-not-found (404).
|
||||||
|
|
||||||
|
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
|
|
||||||
|
RBAC Context — Planes, Roles, and Auditability
|
||||||
|
- The platform MUST maintain two strictly separated authorization planes:
|
||||||
|
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
|
||||||
|
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
||||||
|
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
||||||
|
- Tenant role semantics MUST remain least-privilege:
|
||||||
|
- Readonly: view-only; MUST NOT start operations and MUST NOT mutate data.
|
||||||
|
- Operator: MAY start allowed tenant operations; MUST NOT manage credentials, settings, members, or perform destructive actions.
|
||||||
|
- Manager: MAY manage tenant configuration and start operations; MUST NOT manage tenant memberships (Owner-only).
|
||||||
|
- Owner: MAY manage memberships and all tenant configuration; Owner-only “danger zone” actions MUST remain Owner-only.
|
||||||
|
- The system MUST prevent removing or demoting the last remaining Owner of a tenant.
|
||||||
|
- All access-control relevant changes MUST write `AuditLog` entries with stable action IDs, and MUST be redacted (no secrets).
|
||||||
|
|
||||||
|
RBAC-UX-001 — Server-side is the source of truth
|
||||||
|
- UI visibility / disabled state is never a security boundary.
|
||||||
|
- Every mutating action (create/update/delete/restore/archive/force-delete), every operation start, and every credential/
|
||||||
|
config change MUST enforce authorization server-side via `Gate::authorize(...)` or a Policy method.
|
||||||
|
- Any missing server-side authorization is a P0 security bug.
|
||||||
|
|
||||||
|
RBAC-UX-002 — Deny-as-not-found for non-members
|
||||||
|
- Tenant membership (and plane membership) is an isolation boundary.
|
||||||
|
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
|
||||||
|
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
||||||
|
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
||||||
|
action endpoints (Livewire calls included).
|
||||||
|
|
||||||
|
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
||||||
|
- Within an established tenant scope, missing permissions are authorization failures.
|
||||||
|
- If the actor is a tenant member, but lacks the required capability for an action, the server MUST fail with 403.
|
||||||
|
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
|
||||||
|
|
||||||
|
RBAC-UX-004 — Visible vs disabled UX rule
|
||||||
|
- For tenant members: actions SHOULD be visible but disabled when capability is missing.
|
||||||
|
- Disabled actions MUST provide helper text explaining the missing permission.
|
||||||
|
- For non-members: actions MUST behave as not found (404) and SHOULD NOT leak resource existence.
|
||||||
|
- Exception: highly sensitive controls (e.g., credential rotation) MAY be hidden even for members without permission.
|
||||||
|
|
||||||
|
RBAC-UX-005 — Destructive confirmation standard
|
||||||
|
- All destructive-like actions MUST require confirmation.
|
||||||
|
- Delete/force-delete/archive/restore/remove membership/role downgrade/credential rotation/break-glass enter/exit MUST use
|
||||||
|
`->requiresConfirmation()` and SHOULD include clear warning text.
|
||||||
|
- Confirmation is UX only; authorization still MUST be server-side.
|
||||||
|
|
||||||
|
RBAC-UX-006 — Capability registry is canonical
|
||||||
|
- Capabilities MUST be centrally defined in a single canonical registry (constants/enum).
|
||||||
|
- Feature code MUST reference capabilities only via the registry (no raw string literals).
|
||||||
|
- Role → capability mapping MUST reference only registry entries.
|
||||||
|
- CI MUST fail if unknown/unregistered capabilities are used.
|
||||||
|
|
||||||
|
RBAC-UX-007 — Global search must be tenant-safe
|
||||||
|
- Global search results MUST be scoped to the current tenant.
|
||||||
|
- Non-members MUST never learn about resources in other tenants (no results, no hints).
|
||||||
|
- If a result exists but is not accessible, it MUST be treated as not found (404 semantics).
|
||||||
|
|
||||||
|
RBAC-UX-008 — Regression guards are mandatory
|
||||||
|
- The repo MUST include RBAC regression tests asserting at least:
|
||||||
|
- Readonly cannot mutate or start operations.
|
||||||
|
- Operator can run allowed operations but cannot manage configuration.
|
||||||
|
- Manager/Owner behave according to the role matrix.
|
||||||
|
- The repo SHOULD include an automated “no ad-hoc authorization” guard that blocks new status/permission mappings sprinkled
|
||||||
|
across `app/Filament/**`, pushing patterns into central helpers.
|
||||||
|
|
||||||
|
### Operations / Run Observability Standard
|
||||||
|
- Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations.
|
||||||
|
- An action MUST create/reuse a canonical `OperationRun` and execute asynchronously when any of the following applies:
|
||||||
|
1. It can take > 2 seconds under normal conditions.
|
||||||
|
2. It performs remote/external calls (e.g., Microsoft Graph).
|
||||||
|
3. It is queued or scheduled.
|
||||||
|
4. It is operationally relevant for troubleshooting/audit (“what ran, who started it, did it succeed, what failed?”).
|
||||||
|
- Actions that are DB-only and typically complete in < 2 seconds MAY skip `OperationRun`.
|
||||||
|
- OPS-EX-AUTH-001 — Auth Handshake Exception:
|
||||||
|
- OIDC/SAML login handshakes MAY perform synchronous outbound HTTP (e.g., token exchange) without an `OperationRun`.
|
||||||
|
- Rationale: interactive, session-critical, and not a tenant-operational “background job”.
|
||||||
|
- Guardrail: outbound HTTP for auth handshakes is allowed only on `/auth/*` endpoints and MUST NOT occur on Monitoring/Operations pages.
|
||||||
|
- If an action is security-relevant or affects operational behavior (e.g., “Ignore policy”), it MUST write an `AuditLog` entry
|
||||||
|
including actor, tenant, action, target, before/after, and timestamp.
|
||||||
|
- The `OperationRun` record is the canonical source of truth for Monitoring (status, timestamps, counts, failures),
|
||||||
|
even if implemented by multiple jobs/steps (“umbrella run”).
|
||||||
|
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
|
||||||
|
- Monitoring pages MUST be DB-only at render time (no external calls).
|
||||||
|
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
|
||||||
|
confirm + “View run”.
|
||||||
|
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
|
||||||
|
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
|
||||||
|
in failures or notifications.
|
||||||
|
- Graph calls are allowed only via explicit user interaction and only when delegated auth is present; never as a render side-effect (restore group mapping is intentionally DB-only).
|
||||||
|
- Monitoring → Operations is reserved for `OperationRun`-tracked operations.
|
||||||
|
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||||
|
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
||||||
|
|
||||||
|
### Data Minimization & Safe Logging
|
||||||
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
|
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
|
||||||
|
- Logs MUST not contain secrets/tokens; monitoring MUST rely on run records + error codes (not log parsing).
|
||||||
|
|
||||||
|
### Badge Semantics Are Centralized (BADGE-001)
|
||||||
|
- Status-like badges (status/outcome/severity/risk/availability/boolean signals) MUST render via `BadgeCatalog` / `BadgeRenderer`.
|
||||||
|
- Filament resources/pages/widgets/views MUST NOT introduce ad-hoc status-like badge mappings (use a `BadgeDomain` instead).
|
||||||
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
### Spec-First Workflow
|
### Spec-First Workflow
|
||||||
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
||||||
@ -26,10 +156,22 @@ ### Spec-First Workflow
|
|||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
|
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
|
||||||
- Run `./vendor/bin/pint --dirty` before finalizing.
|
- Run `./vendor/bin/sail bin pint --dirty` before finalizing.
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
|
||||||
- Restore semantics changes require: spec update, checklist update, and tests proving safety.
|
|
||||||
|
|
||||||
**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03
|
### Scope & Compliance
|
||||||
|
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||||
|
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||||
|
|
||||||
|
### Amendment Procedure
|
||||||
|
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||||
|
- The PR MUST include a short rationale and list of impacted templates/specs.
|
||||||
|
- Amendments MUST update **Last Amended** date.
|
||||||
|
|
||||||
|
### Versioning Policy (SemVer)
|
||||||
|
- **PATCH**: clarifications/typos/non-semantic refinements.
|
||||||
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
|
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
# Implementation Plan: TenantPilot v1
|
# Implementation Plan: TenantPilot v1 (LEGACY / DEPRECATED)
|
||||||
|
|
||||||
|
> DEPRECATED: Do not use `.specify/plan.md` for new work.
|
||||||
|
> Active feature plans live under `specs/<NNN>-<slug>/plan.md` on `feat/<NNN>-<slug>` branches.
|
||||||
|
> Legacy history lives under `spechistory/`.
|
||||||
|
|
||||||
**Branch**: `dev`
|
**Branch**: `dev`
|
||||||
**Date**: 2026-01-03
|
**Date**: 2026-01-03
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
# Research T186 — settings_apply capability verification
|
# Research T186 — settings_apply capability verification (LEGACY / DEPRECATED)
|
||||||
|
|
||||||
|
> DEPRECATED: Do not add new research notes under `.specify/`.
|
||||||
|
> Active feature research should live under `specs/<NNN>-<slug>/`.
|
||||||
|
> Legacy history lives under `spechistory/`.
|
||||||
|
|
||||||
Objective
|
Objective
|
||||||
---------
|
---------
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
# Feature Specification: TenantPilot v1
|
# Feature Specification: TenantPilot v1 (LEGACY / DEPRECATED)
|
||||||
|
|
||||||
|
> DEPRECATED: Do not use `.specify/spec.md` for new work.
|
||||||
|
> Active feature specs live under `specs/<NNN>-<slug>/spec.md` on `feat/<NNN>-<slug>` branches.
|
||||||
|
> Legacy history lives under `spechistory/`.
|
||||||
|
|
||||||
**Feature Branch**: `dev`
|
**Feature Branch**: `dev`
|
||||||
**Created**: 2025-12-10
|
**Created**: 2025-12-10
|
||||||
|
|||||||
@ -2,7 +2,11 @@
|
|||||||
description: "Task list for TenantPilot v1 implementation"
|
description: "Task list for TenantPilot v1 implementation"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Tasks: TenantPilot v1
|
# Tasks: TenantPilot v1 (LEGACY / DEPRECATED)
|
||||||
|
|
||||||
|
> DEPRECATED: Do not use `.specify/tasks.md` for new work.
|
||||||
|
> Active feature task lists live under `specs/<NNN>-<slug>/tasks.md` on `feat/<NNN>-<slug>` branches.
|
||||||
|
> Legacy history lives under `spechistory/`.
|
||||||
|
|
||||||
**Input**: Design documents from `.specify/spec.md` and `.specify/plan.md`
|
**Input**: Design documents from `.specify/spec.md` and `.specify/plan.md`
|
||||||
**Prerequisites**: plan.md (complete), spec.md (complete)
|
**Prerequisites**: plan.md (complete), spec.md (complete)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Implementation Plan: [FEATURE]
|
|||||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@ -31,7 +31,18 @@ ## Constitution Check
|
|||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
[Gates determined based on constitution file]
|
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||||
|
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||||
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
|
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||||
|
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||||
|
- 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
|
||||||
|
- 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`
|
||||||
|
- 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
|
||||||
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@ -77,6 +77,29 @@ ### Edge Cases
|
|||||||
|
|
||||||
## Requirements *(mandatory)*
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||||
|
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||||
|
(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.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
|
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||||
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
|
- explicitly define 404 vs 403 semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403
|
||||||
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||||
|
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||||
|
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
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.
|
||||||
|
|||||||
@ -8,7 +8,24 @@ # Tasks: [FEATURE NAME]
|
|||||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
|
||||||
|
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
|
||||||
|
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
|
||||||
|
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
|
||||||
|
without an `OperationRun`.
|
||||||
|
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||||
|
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||||
|
- explicit 404 vs 403 semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403,
|
||||||
|
- capability registry usage (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||||
|
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||||
|
- at least one positive + one negative authorization test.
|
||||||
|
**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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
|||||||
516
Agents.md
516
Agents.md
@ -26,9 +26,9 @@ ## Scope Reference
|
|||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/constitution.md`
|
||||||
2. For new work: create/update `.specify/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `.specify/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `.specify/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
@ -147,6 +147,52 @@ # Reset to before the conflict
|
|||||||
# Or stash conflicting changes
|
# Or stash conflicting changes
|
||||||
git stash push -m "conflicting-agent-work-$(date +%s)"
|
git stash push -m "conflicting-agent-work-$(date +%s)"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Solo + Copilot Workflow (Konflikte vermeiden)
|
||||||
|
|
||||||
|
Wenn du alleine arbeitest (du + Copilot), sind große Konflikt-Stürme fast immer „Branch drift“: `dev` bewegt sich weiter, das Feature hängt hinterher. Diese Regeln halten Feature-Branches mergebar.
|
||||||
|
|
||||||
|
### Regel 1: Vor jeder Troubleshooting-Änderung zuerst `dev` ins Feature holen
|
||||||
|
|
||||||
|
Bevor du einen kleinen Fix auf einem Feature-Branch machst (z.B. `config/`, `tests/`, shared Services), synchronisiere:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout feat/<NNN>-<slug>
|
||||||
|
git merge origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regel 2: Kurzlebige „Session Branches“ auch im Solo-Setup
|
||||||
|
|
||||||
|
Auch wenn du alleine bist: nutze Session-Branches für gezielte Fixes, damit du jederzeit sauber abbrechen kannst.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout feat/<NNN>-<slug>
|
||||||
|
git checkout -b $(git branch --show-current)-session-$(date +%s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach wie gewohnt committen, testen, zurück-merge:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SESSION_BRANCH=$(git branch --show-current)
|
||||||
|
ORIGINAL_BRANCH=$(git branch --show-current | sed 's/-session-[0-9]*$//')
|
||||||
|
git checkout $ORIGINAL_BRANCH
|
||||||
|
git merge $SESSION_BRANCH --no-ff -m "merge: agent session work"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regel 3: „Globale“ Fixes als Mini-PR nach `dev`
|
||||||
|
|
||||||
|
Wenn ein Fix nicht wirklich feature-spezifisch ist (z.B. `config/graph_contracts.php`, Test-Bootstrap, allgemeine Graph-Validation), dann:
|
||||||
|
|
||||||
|
- Mini-Branch von `dev` erstellen und PR → `dev` mergen.
|
||||||
|
- Danach im Feature-Branch einfach wieder `origin/dev` mergen.
|
||||||
|
|
||||||
|
Das reduziert Add/Add-Konflikte drastisch, weil `dev` die gemeinsame Wahrheit bleibt.
|
||||||
|
|
||||||
|
### Regel 4: Kein Rebase auf geteilten Branches
|
||||||
|
|
||||||
|
Wenn du und Copilot über längere Zeit auf demselben Feature-Branch arbeiten, bleib bei `merge origin/dev` (kein Rebase), damit die Historie stabil bleibt.
|
||||||
|
|
||||||
## Architecture Assumptions
|
## Architecture Assumptions
|
||||||
- Backend: Laravel (latest stable)
|
- Backend: Laravel (latest stable)
|
||||||
- Admin UI: Filament
|
- Admin UI: Filament
|
||||||
@ -224,7 +270,7 @@ ## Engineering Rules
|
|||||||
- Keep Microsoft Graph integration isolated behind a dedicated abstraction layer.
|
- Keep Microsoft Graph integration isolated behind a dedicated abstraction layer.
|
||||||
- Use dependency injection and clear interfaces for Graph clients.
|
- Use dependency injection and clear interfaces for Graph clients.
|
||||||
- No breaking changes to data structures or API contracts without updating:
|
- No breaking changes to data structures or API contracts without updating:
|
||||||
- `.specify/spec.md`
|
- `specs/<NNN>-<slug>/spec.md`
|
||||||
- migration notes
|
- migration notes
|
||||||
- upgrade steps
|
- upgrade steps
|
||||||
- If a TypeScript/JS tooling package exists, use strict typing rules there too.
|
- If a TypeScript/JS tooling package exists, use strict typing rules there too.
|
||||||
@ -340,6 +386,264 @@ ## Reference Materials
|
|||||||
===
|
===
|
||||||
|
|
||||||
<laravel-boost-guidelines>
|
<laravel-boost-guidelines>
|
||||||
|
=== .ai/filament-v5-blueprint rules ===
|
||||||
|
|
||||||
|
## Source of Truth
|
||||||
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
|
- docs/research/filament-v5-notes.md
|
||||||
|
and prefer that over guesses.
|
||||||
|
|
||||||
|
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||||
|
|
||||||
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
|
## 1) Non-negotiables
|
||||||
|
- Filament v5 requires Livewire v4.0+.
|
||||||
|
- 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.
|
||||||
|
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||||
|
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
|
## 2) Directory & naming conventions
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
|
## 3) Panel setup defaults
|
||||||
|
- 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.
|
||||||
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
|
- Assets policy:
|
||||||
|
- Panel-only assets: register via panel config.
|
||||||
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
|
- Deployment must include `php artisan filament:assets`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 4) Navigation & information architecture
|
||||||
|
- 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.
|
||||||
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
|
- User menu:
|
||||||
|
- Configure via `userMenuItems()` with Action objects.
|
||||||
|
- Never put destructive actions there without confirmation + authorization.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
|
## 5) Resource patterns
|
||||||
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
|
- Global search:
|
||||||
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
|
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||||
|
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||||
|
- For very large datasets: consider disabling term splitting (only when needed).
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
|
## 6) Page lifecycle & query rules
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
|
- Inline CRUD inside owner form → Repeater.
|
||||||
|
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||||
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
- 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).
|
||||||
|
- Custom field views must obey state binding modifiers.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
|
## 9) Table & action patterns
|
||||||
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
|
- Actions:
|
||||||
|
- Execution actions use `->action(...)`.
|
||||||
|
- Destructive actions add `->requiresConfirmation()`.
|
||||||
|
- Navigation-only actions should use `->url(...)`.
|
||||||
|
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
|
## 10) Authorization & security
|
||||||
|
- Enforce panel access in non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/users/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
|
## 11) Notifications & UX feedback
|
||||||
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
|
## 12) Performance defaults
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 13) Testing requirements
|
||||||
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
|
- Test actions using Filament’s action testing guidance.
|
||||||
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
|
## 14) Forbidden patterns
|
||||||
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
|
- Destructive actions without `->requiresConfirmation()`.
|
||||||
|
- Shipping heavy assets globally when on-demand loading fits.
|
||||||
|
- Publishing Filament internal views as a default customization technique.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 15) Agent output contract
|
||||||
|
For any implementation request, the agent must explicitly state:
|
||||||
|
1) Livewire v4.0+ compliance.
|
||||||
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
|
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||||
|
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||||
|
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||||
|
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
|
=== .ai/filament-v5-checklist rules ===
|
||||||
|
|
||||||
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
|
## Version Safety
|
||||||
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
|
- 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).
|
||||||
|
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
|
## Panel & Navigation
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||||
|
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||||
|
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||||
|
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||||
|
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
|
## Resource Structure
|
||||||
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
|
- 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.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||||
|
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
|
## Infolists & Relations
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Tables & Actions
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
|
## Authorization & Security
|
||||||
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
|
## UX & Notifications
|
||||||
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
|
- [ ] 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)”
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] 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?”
|
||||||
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
|
## Deployment / Ops
|
||||||
|
- [ ] `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”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
@ -350,10 +654,11 @@ ## 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
|
||||||
- filament/filament (FILAMENT) - v4
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- livewire/livewire (LIVEWIRE) - v3
|
- laravel/socialite (SOCIALITE) - v5
|
||||||
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
- laravel/mcp (MCP) - v0
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
@ -362,20 +667,19 @@ ## Foundational Context
|
|||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
## 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, 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.
|
||||||
- UI consistency: Prefer Filament components (`<x-filament::section>`, infolist/table entries, etc.) over custom HTML/Tailwind for admin UI; only roll custom markup when Filament cannot express the UI.
|
|
||||||
|
|
||||||
## 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 it works. 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 `npm run build`, `npm run dev`, or `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.
|
||||||
@ -383,17 +687,16 @@ ## Replies
|
|||||||
## Documentation Files
|
## Documentation Files
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
|
||||||
=== 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.
|
||||||
@ -404,22 +707,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
|
|||||||
- 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. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific 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 any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
- 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.
|
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
- Use multiple, broad, simple, topic-based queries 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.
|
- 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".
|
||||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
@ -430,7 +732,7 @@ ## PHP
|
|||||||
### Constructors
|
### Constructors
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
- Do not allow empty `__construct()` methods with zero parameters.
|
- 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.
|
||||||
@ -444,7 +746,7 @@ ### Type Declarations
|
|||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
- Prefer PHPDoc blocks over 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 there is something very complex going on.
|
||||||
|
|
||||||
## PHPDoc Blocks
|
## PHPDoc Blocks
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
@ -452,32 +754,44 @@ ## PHPDoc Blocks
|
|||||||
## Enums
|
## Enums
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
=== sail rules ===
|
||||||
|
|
||||||
|
## Laravel Sail
|
||||||
|
|
||||||
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
|
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||||
|
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||||
|
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||||
|
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||||
|
- Install Composer packages: `vendor/bin/sail composer install`
|
||||||
|
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||||
|
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||||
|
- 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 `php artisan test` 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 `php 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 `php 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.
|
||||||
- Generate code that prevents N+1 query problems by using eager loading.
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
- 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 `php 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.
|
||||||
@ -501,41 +815,41 @@ ### 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 `php 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 `npm run build` or ask the user to run `npm run dev` or `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 ===
|
||||||
|
|
||||||
## Laravel 12
|
## Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version specific documentation.
|
- 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
|
||||||
- No middleware files in `app/Http/Middleware/`.
|
- 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()`.
|
||||||
- `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.
|
||||||
- `bootstrap/providers.php` contains application specific service providers.
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
- **No app\Console\Kernel.php** - 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.
|
||||||
- **Commands auto-register** - files 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 11 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/core rules ===
|
||||||
|
|
||||||
## Livewire Core
|
## Livewire
|
||||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
|
||||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
- 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.
|
- 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.
|
||||||
|
|
||||||
## Livewire Best Practices
|
## Livewire Best Practices
|
||||||
- Livewire components require a single root element.
|
- Livewire components require a single root element.
|
||||||
@ -552,15 +866,14 @@ ## Livewire Best Practices
|
|||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||||
|
|
||||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||||
public function mount(User $user) { $this->user = $user; }
|
public function mount(User $user) { $this->user = $user; }
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
public function updatedSearch() { $this->resetPage(); }
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
## Testing Livewire
|
## Testing Livewire
|
||||||
|
|
||||||
<code-snippet name="Example Livewire component test" lang="php">
|
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||||
Livewire::test(Counter::class)
|
Livewire::test(Counter::class)
|
||||||
->assertSet('count', 0)
|
->assertSet('count', 0)
|
||||||
->call('increment')
|
->call('increment')
|
||||||
@ -569,56 +882,17 @@ ## Testing Livewire
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
$this->get('/posts/create')
|
||||||
$this->get('/posts/create')
|
->assertSeeLivewire(CreatePost::class);
|
||||||
->assertSeeLivewire(CreatePost::class);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
|
|
||||||
=== livewire/v3 rules ===
|
|
||||||
|
|
||||||
## Livewire 3
|
|
||||||
|
|
||||||
### Key Changes From Livewire 2
|
|
||||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
|
||||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
|
||||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
|
||||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
|
||||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
|
||||||
|
|
||||||
### New Directives
|
|
||||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
|
||||||
|
|
||||||
### Alpine
|
|
||||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
|
||||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
|
||||||
|
|
||||||
### Lifecycle Hooks
|
|
||||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
|
||||||
|
|
||||||
<code-snippet name="livewire:load example" lang="js">
|
|
||||||
document.addEventListener('livewire:init', function () {
|
|
||||||
Livewire.hook('request', ({ fail }) => {
|
|
||||||
if (fail && fail.status === 419) {
|
|
||||||
alert('Your session expired');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Livewire.hook('message.failed', (message, component) => {
|
|
||||||
console.error(message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/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 `vendor/bin/pint --test`, simply run `vendor/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 ===
|
||||||
|
|
||||||
@ -627,7 +901,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 `php 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.
|
||||||
@ -640,9 +914,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: `php artisan test`.
|
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
||||||
- To run all tests in a file: `php artisan test 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: `php artisan test --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
|
||||||
@ -661,7 +935,7 @@ ### Mocking
|
|||||||
- You can also create partial mocks using the same import or self method.
|
- You can also create partial mocks using the same import or self method.
|
||||||
|
|
||||||
### Datasets
|
### Datasets
|
||||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
- 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">
|
<code-snippet name="Pest Dataset Example" lang="php">
|
||||||
it('has emails', function (string $email) {
|
it('has emails', function (string $email) {
|
||||||
@ -672,18 +946,17 @@ ### Datasets
|
|||||||
]);
|
]);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== pest/v4 rules ===
|
=== pest/v4 rules ===
|
||||||
|
|
||||||
## Pest 4
|
## Pest 4
|
||||||
|
|
||||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
- 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 testing is incredibly powerful and useful for this project.
|
||||||
- Browser tests should live in `tests/Browser/`.
|
- Browser tests should live in `tests/Browser/`.
|
||||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||||
|
|
||||||
### Browser Testing
|
### Browser Testing
|
||||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
- 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.
|
- 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 multiple browsers (Chrome, Firefox, Safari).
|
||||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||||
@ -717,39 +990,37 @@ ### Example Tests
|
|||||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
## Tailwind Core
|
## Tailwind CSS
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
- 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.
|
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||||
|
|
||||||
### Spacing
|
### Spacing
|
||||||
- When listing items, use gap utilities for spacing, don't use margins.
|
- 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>
|
|
||||||
|
|
||||||
|
<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
|
### 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:`.
|
- 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 ===
|
=== tailwindcss/v4 rules ===
|
||||||
|
|
||||||
## Tailwind 4
|
## Tailwind CSS 4
|
||||||
|
|
||||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
- `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.
|
- 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">
|
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||||
@theme {
|
@theme {
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
@ -765,9 +1036,8 @@ ## Tailwind 4
|
|||||||
+ @import "tailwindcss";
|
+ @import "tailwindcss";
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
### Replaced Utilities
|
### Replaced Utilities
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||||
- Opacity values are still numeric.
|
- Opacity values are still numeric.
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
| Deprecated | Replacement |
|
||||||
@ -784,3 +1054,11 @@ ### Replaced Utilities
|
|||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| decoration-clone | box-decoration-clone |
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4
|
||||||
|
- PostgreSQL (Sail)
|
||||||
|
- Tailwind CSS v4
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts)
|
||||||
|
|||||||
469
GEMINI.md
469
GEMINI.md
@ -26,9 +26,9 @@ ## Scope Reference
|
|||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/constitution.md`
|
||||||
2. For new work: create/update `.specify/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `.specify/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `.specify/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
@ -110,7 +110,7 @@ ## Engineering Rules
|
|||||||
- Keep Microsoft Graph integration isolated behind a dedicated abstraction layer.
|
- Keep Microsoft Graph integration isolated behind a dedicated abstraction layer.
|
||||||
- Use dependency injection and clear interfaces for Graph clients.
|
- Use dependency injection and clear interfaces for Graph clients.
|
||||||
- No breaking changes to data structures or API contracts without updating:
|
- No breaking changes to data structures or API contracts without updating:
|
||||||
- `.specify/spec.md`
|
- `specs/<NNN>-<slug>/spec.md`
|
||||||
- migration notes
|
- migration notes
|
||||||
- upgrade steps
|
- upgrade steps
|
||||||
- If a TypeScript/JS tooling package exists, use strict typing rules there too.
|
- If a TypeScript/JS tooling package exists, use strict typing rules there too.
|
||||||
@ -226,6 +226,264 @@ ## Reference Materials
|
|||||||
===
|
===
|
||||||
|
|
||||||
<laravel-boost-guidelines>
|
<laravel-boost-guidelines>
|
||||||
|
=== .ai/filament-v5-blueprint rules ===
|
||||||
|
|
||||||
|
## Source of Truth
|
||||||
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
|
- docs/research/filament-v5-notes.md
|
||||||
|
and prefer that over guesses.
|
||||||
|
|
||||||
|
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
||||||
|
|
||||||
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
|
## 1) Non-negotiables
|
||||||
|
- Filament v5 requires Livewire v4.0+.
|
||||||
|
- 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.
|
||||||
|
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
|
||||||
|
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
|
## 2) Directory & naming conventions
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
|
## 3) Panel setup defaults
|
||||||
|
- 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.
|
||||||
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
|
- Assets policy:
|
||||||
|
- Panel-only assets: register via panel config.
|
||||||
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
|
- Deployment must include `php artisan filament:assets`.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 4) Navigation & information architecture
|
||||||
|
- 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.
|
||||||
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
|
- User menu:
|
||||||
|
- Configure via `userMenuItems()` with Action objects.
|
||||||
|
- Never put destructive actions there without confirmation + authorization.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/clusters
|
||||||
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
|
## 5) Resource patterns
|
||||||
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
|
- Global search:
|
||||||
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
|
- Otherwise disable global search for that resource (don’t “expect it to work”).
|
||||||
|
- If global search renders relationship-backed details: eager-load via global search query override.
|
||||||
|
- For very large datasets: consider disabling term splitting (only when needed).
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
|
## 6) Page lifecycle & query rules
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
|
- Inline CRUD inside owner form → Repeater.
|
||||||
|
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/managing-relationships
|
||||||
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
- 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).
|
||||||
|
- Custom field views must obey state binding modifiers.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
|
## 9) Table & action patterns
|
||||||
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
|
- Actions:
|
||||||
|
- Execution actions use `->action(...)`.
|
||||||
|
- Destructive actions add `->requiresConfirmation()`.
|
||||||
|
- Navigation-only actions should use `->url(...)`.
|
||||||
|
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/tables/empty-state
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
|
## 10) Authorization & security
|
||||||
|
- Enforce panel access in non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/users/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
|
## 11) Notifications & UX feedback
|
||||||
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/notifications/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
|
## 12) Performance defaults
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
|
## 13) Testing requirements
|
||||||
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
|
- Test actions using Filament’s action testing guidance.
|
||||||
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/overview
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
|
## 14) Forbidden patterns
|
||||||
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
|
- Destructive actions without `->requiresConfirmation()`.
|
||||||
|
- Shipping heavy assets globally when on-demand loading fits.
|
||||||
|
- Publishing Filament internal views as a default customization technique.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
|
## 15) Agent output contract
|
||||||
|
For any implementation request, the agent must explicitly state:
|
||||||
|
1) Livewire v4.0+ compliance.
|
||||||
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
|
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
|
||||||
|
4) Which actions are destructive and how confirmation + authorization is handled.
|
||||||
|
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
|
||||||
|
6) Testing plan: which pages/widgets/relation managers/actions are covered.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://filamentphp.com/docs/5.x/upgrade-guide
|
||||||
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
|
=== .ai/filament-v5-checklist rules ===
|
||||||
|
|
||||||
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
|
## Version Safety
|
||||||
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
|
- 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).
|
||||||
|
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
|
## Panel & Navigation
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
|
||||||
|
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
|
||||||
|
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
|
||||||
|
- [ ] Cluster directory structure is treated as recommended, not mandatory.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
|
||||||
|
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
|
## Resource Structure
|
||||||
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
|
- 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.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
|
||||||
|
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
|
## Infolists & Relations
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Tables & Actions
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
|
## Authorization & Security
|
||||||
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
|
- 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.
|
||||||
|
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
|
## UX & Notifications
|
||||||
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
|
- [ ] 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)”
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
- [ ] 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”
|
||||||
|
- [ ] 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”
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] 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?”
|
||||||
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
|
## Deployment / Ops
|
||||||
|
- [ ] `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”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
@ -236,10 +494,11 @@ ## 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
|
||||||
- filament/filament (FILAMENT) - v4
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- livewire/livewire (LIVEWIRE) - v3
|
- laravel/socialite (SOCIALITE) - v5
|
||||||
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
- laravel/mcp (MCP) - v0
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
@ -248,7 +507,7 @@ ## Foundational Context
|
|||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
## 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, 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.
|
||||||
|
|
||||||
@ -256,11 +515,11 @@ ## 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 it works. 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 `npm run build`, `npm run dev`, or `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.
|
||||||
@ -268,17 +527,16 @@ ## Replies
|
|||||||
## Documentation Files
|
## Documentation Files
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
|
||||||
=== 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.
|
||||||
@ -289,22 +547,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
|
|||||||
- 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. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific 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 any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
- 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.
|
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
- Use multiple, broad, simple, topic-based queries 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.
|
- 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".
|
||||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
@ -315,7 +572,7 @@ ## PHP
|
|||||||
### Constructors
|
### Constructors
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
- Do not allow empty `__construct()` methods with zero parameters.
|
- 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.
|
||||||
@ -329,7 +586,7 @@ ### Type Declarations
|
|||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
- Prefer PHPDoc blocks over 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 there is something very complex going on.
|
||||||
|
|
||||||
## PHPDoc Blocks
|
## PHPDoc Blocks
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
@ -337,32 +594,44 @@ ## PHPDoc Blocks
|
|||||||
## Enums
|
## Enums
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
=== sail rules ===
|
||||||
|
|
||||||
|
## Laravel Sail
|
||||||
|
|
||||||
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
|
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||||
|
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||||
|
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||||
|
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||||
|
- Install Composer packages: `vendor/bin/sail composer install`
|
||||||
|
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||||
|
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||||
|
- 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 `php artisan test` 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 `php 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 `php 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.
|
||||||
- Generate code that prevents N+1 query problems by using eager loading.
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
- 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 `php 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.
|
||||||
@ -386,41 +655,41 @@ ### 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 `php 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 `npm run build` or ask the user to run `npm run dev` or `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 ===
|
||||||
|
|
||||||
## Laravel 12
|
## Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version specific documentation.
|
- 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
|
||||||
- No middleware files in `app/Http/Middleware/`.
|
- 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()`.
|
||||||
- `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.
|
||||||
- `bootstrap/providers.php` contains application specific service providers.
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
- **No app\Console\Kernel.php** - 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.
|
||||||
- **Commands auto-register** - files 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 11 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/core rules ===
|
||||||
|
|
||||||
## Livewire Core
|
## Livewire
|
||||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
|
||||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
- 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.
|
- 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.
|
||||||
|
|
||||||
## Livewire Best Practices
|
## Livewire Best Practices
|
||||||
- Livewire components require a single root element.
|
- Livewire components require a single root element.
|
||||||
@ -437,15 +706,14 @@ ## Livewire Best Practices
|
|||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||||
|
|
||||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||||
public function mount(User $user) { $this->user = $user; }
|
public function mount(User $user) { $this->user = $user; }
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
public function updatedSearch() { $this->resetPage(); }
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
## Testing Livewire
|
## Testing Livewire
|
||||||
|
|
||||||
<code-snippet name="Example Livewire component test" lang="php">
|
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||||
Livewire::test(Counter::class)
|
Livewire::test(Counter::class)
|
||||||
->assertSet('count', 0)
|
->assertSet('count', 0)
|
||||||
->call('increment')
|
->call('increment')
|
||||||
@ -454,56 +722,17 @@ ## Testing Livewire
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
$this->get('/posts/create')
|
||||||
$this->get('/posts/create')
|
->assertSeeLivewire(CreatePost::class);
|
||||||
->assertSeeLivewire(CreatePost::class);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
|
|
||||||
=== livewire/v3 rules ===
|
|
||||||
|
|
||||||
## Livewire 3
|
|
||||||
|
|
||||||
### Key Changes From Livewire 2
|
|
||||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
|
||||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
|
||||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
|
||||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
|
||||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
|
||||||
|
|
||||||
### New Directives
|
|
||||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
|
||||||
|
|
||||||
### Alpine
|
|
||||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
|
||||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
|
||||||
|
|
||||||
### Lifecycle Hooks
|
|
||||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
|
||||||
|
|
||||||
<code-snippet name="livewire:load example" lang="js">
|
|
||||||
document.addEventListener('livewire:init', function () {
|
|
||||||
Livewire.hook('request', ({ fail }) => {
|
|
||||||
if (fail && fail.status === 419) {
|
|
||||||
alert('Your session expired');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Livewire.hook('message.failed', (message, component) => {
|
|
||||||
console.error(message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/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 `vendor/bin/pint --test`, simply run `vendor/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 ===
|
||||||
|
|
||||||
@ -512,7 +741,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 `php 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.
|
||||||
@ -525,9 +754,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: `php artisan test`.
|
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
||||||
- To run all tests in a file: `php artisan test 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: `php artisan test --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
|
||||||
@ -546,7 +775,7 @@ ### Mocking
|
|||||||
- You can also create partial mocks using the same import or self method.
|
- You can also create partial mocks using the same import or self method.
|
||||||
|
|
||||||
### Datasets
|
### Datasets
|
||||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
- 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">
|
<code-snippet name="Pest Dataset Example" lang="php">
|
||||||
it('has emails', function (string $email) {
|
it('has emails', function (string $email) {
|
||||||
@ -557,18 +786,17 @@ ### Datasets
|
|||||||
]);
|
]);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== pest/v4 rules ===
|
=== pest/v4 rules ===
|
||||||
|
|
||||||
## Pest 4
|
## Pest 4
|
||||||
|
|
||||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
- 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 testing is incredibly powerful and useful for this project.
|
||||||
- Browser tests should live in `tests/Browser/`.
|
- Browser tests should live in `tests/Browser/`.
|
||||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||||
|
|
||||||
### Browser Testing
|
### Browser Testing
|
||||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
- 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.
|
- 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 multiple browsers (Chrome, Firefox, Safari).
|
||||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||||
@ -602,39 +830,37 @@ ### Example Tests
|
|||||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
## Tailwind Core
|
## Tailwind CSS
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
- 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.
|
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||||
|
|
||||||
### Spacing
|
### Spacing
|
||||||
- When listing items, use gap utilities for spacing, don't use margins.
|
- 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>
|
|
||||||
|
|
||||||
|
<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
|
### 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:`.
|
- 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 ===
|
=== tailwindcss/v4 rules ===
|
||||||
|
|
||||||
## Tailwind 4
|
## Tailwind CSS 4
|
||||||
|
|
||||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
- `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.
|
- 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">
|
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||||
@theme {
|
@theme {
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
@ -650,9 +876,8 @@ ## Tailwind 4
|
|||||||
+ @import "tailwindcss";
|
+ @import "tailwindcss";
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
### Replaced Utilities
|
### Replaced Utilities
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||||
- Opacity values are still numeric.
|
- Opacity values are still numeric.
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
| Deprecated | Replacement |
|
||||||
@ -669,3 +894,11 @@ ### Replaced Utilities
|
|||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| decoration-clone | box-decoration-clone |
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 065-tenant-rbac-v1: Added PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4
|
||||||
|
- 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||||
|
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 (065-tenant-rbac-v1)
|
||||||
|
|||||||
72
PROJECT_SUMMARY.md
Normal file
72
PROJECT_SUMMARY.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
**TenantPilot / TenantAtlas — Project Summary**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
**Tech Stack & Key Libraries:**
|
||||||
|
- **Backend:** Laravel 12 (PHP 8.4)
|
||||||
|
- **Admin UI:** Filament v4
|
||||||
|
- **Realtime/UI:** Livewire v3
|
||||||
|
- **Testing:** Pest v4, PHPUnit
|
||||||
|
- **Local dev:** Laravel Sail (Docker)
|
||||||
|
- **Styling/tooling:** Tailwind v4, Vite
|
||||||
|
|
||||||
|
**Repository Layout (high level):**
|
||||||
|
- `app/` — application code (Services, Models, Filament resources, Livewire components)
|
||||||
|
- `config/` — runtime configuration (important: `tenantpilot.php`, `graph_contracts.php`)
|
||||||
|
- `specs/` — SpecKit feature specs (feature-by-feature directories, e.g. `011-restore-run-wizard`)
|
||||||
|
- `tests/` — Pest tests (Feature / Unit)
|
||||||
|
- `resources/`, `routes/`, `database/` — standard Laravel layout
|
||||||
|
|
||||||
|
**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`.)
|
||||||
|
- **Restore Preview (dry-run) / Restore Wizard:** implemented — preview/dry-run mode that validates snapshots and highlights conflicts and risks. Feature tracked in `specs/011-restore-run-wizard` and Filament UI resources. Recent fixes improved OData @odata.type handling so derived Graph types validate correctly.
|
||||||
|
- **Assignments & Foundation Mappings:** implemented — mapping and selective restore of assignments, scope tags, and assignment filters (AssignmentRestoreService, FoundationMappingService).
|
||||||
|
- **Settings Catalog / Configuration Policies:** implemented — Settings Catalog and related configuration policy flows (`settingsCatalogPolicy`, `endpointSecurityPolicy`, `securityBaselinePolicy`) with subresource hydration for settings.
|
||||||
|
- **Windows Update Profiles:** implemented — Windows Update Rings + Feature/Quality/Driver update profiles (`windowsUpdateRing`, `windowsFeatureUpdateProfile`, etc.).
|
||||||
|
- **Endpoint Security + App Management:** implemented — Endpoint Security Intents/Policies, App Protection (MAM), Managed App Configurations, mobile app metadata capture.
|
||||||
|
- **Scripts & Autopilot:** implemented — device management scripts, proactive remediations, Autopilot profiles.
|
||||||
|
- **Conditional Access & High-risk types:** partially supported — `conditionalAccessPolicy` is backed up but marked `preview-only` for restore (see `tenantpilot.supported_policy_types`).
|
||||||
|
|
||||||
|
**Policy Type Restore/Backup Surface (from `config/tenantpilot.php`):**
|
||||||
|
- Restore `enabled` (full backup & restore): many configuration, compliance, endpoint security, scripts, Autopilot, MAM types (examples: `deviceConfiguration`, `groupPolicyConfiguration`, `settingsCatalogPolicy`, `deviceCompliancePolicy`, `appProtectionPolicy`, `endpointSecurityPolicy`, `deviceManagementScript`, etc.)
|
||||||
|
- Restore `preview-only` (backup available but live restore disabled / preview only): `conditionalAccessPolicy`, `deviceEnrollmentLimitConfiguration`, `deviceEnrollmentPlatformRestrictionsConfiguration`, `deviceEnrollmentNotificationConfiguration`, `enrollmentRestriction`, `securityBaselinePolicy`.
|
||||||
|
- Backup `metadata-only`: `mobileApp` (apps captured as metadata only)
|
||||||
|
|
||||||
|
**Graph Contract Registry & OData handling:**
|
||||||
|
- Centralized contract registry in `config/graph_contracts.php` defines endpoints, allowed selects/expands, `type_family` (accepted @odata.type values), subresources, and CRUD paths per policy type.
|
||||||
|
- OData validation is used during preview/restore (see `app/Support/Concerns/InteractsWithODataTypes.php`). Recent work broadened `type_family` entries to accept derived Graph @odata.type values for several policy types to avoid false `odata_mismatch` failures.
|
||||||
|
|
||||||
|
**Testing & Quality:**
|
||||||
|
- Unit and feature tests exist (Pest). Recent adjustments ensure unit tests run with the Laravel TestCase/IOC container (fixes issues like `Target class [config] does not exist`).
|
||||||
|
- Code formatting via Laravel Pint is used in CI and local flows.
|
||||||
|
|
||||||
|
**Specs & Process:**
|
||||||
|
- Uses Spec Kit convention: each feature in `specs/<NNN>-<slug>/` (spec.md, plan.md, tasks.md). Branching conventions documented in `Agents.md`/`.specify`.
|
||||||
|
- Default integration branch: `dev`. Feature branches are `feat/<NNN>-<slug>`.
|
||||||
|
|
||||||
|
**Recent Notable Work (context):**
|
||||||
|
- Fixed false `@odata.type mismatch` errors by updating `graph_contracts` `type_family` lists and adding unit tests (`tests/Unit/ODataTypeValidationTest.php`).
|
||||||
|
- Resolved merge conflicts between `feat/011-restore-run-wizard` and `dev` by merging `origin/dev` into a session branch and reconciling spec/test additions.
|
||||||
|
- 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):**
|
||||||
|
- Restore flows: `app/Services/Intune/RestoreService.php`, `app/Services/Intune/RestoreRiskChecker.php`, `app/Services/Intune/RestoreDiffGenerator.php`.
|
||||||
|
- Graph contracts: `config/graph_contracts.php` and `app/Services/Graph/GraphContractRegistry.php`.
|
||||||
|
- Policy type catalog and UX metadata: `config/tenantpilot.php` and `specs/*` for feature intentions.
|
||||||
|
- Filament UI: `app/Filament/Resources/*` (PolicyVersionResource, restore wizard pages).
|
||||||
|
|
||||||
|
**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).
|
||||||
|
- Improve app restore for `mobileApp` beyond metadata-only (complex due to package/artifact handling).
|
||||||
|
- Add broader OData type families as Microsoft Graph evolves; consider automated discovery/updates to `graph_contracts`.
|
||||||
|
- Expand test coverage for edge-case restores (multi-tenant mapping, assignment conflicts, scope tag drift).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want, I can:
|
||||||
|
- open a PR that adds this file to `dev` (I can create a small `chore/` branch and push it), or
|
||||||
|
- expand any section above into a more detailed README or developer onboarding doc.
|
||||||
@ -35,6 +35,13 @@ ## Bulk operations (Feature 005)
|
|||||||
- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`).
|
- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`).
|
||||||
- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs.
|
- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **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`.
|
||||||
|
- 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.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.
|
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.
|
||||||
|
|||||||
116
app/Console/Commands/OpsReconcileAdapterRuns.php
Normal file
116
app/Console/Commands/OpsReconcileAdapterRuns.php
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\AdapterRunReconciler;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class OpsReconcileAdapterRuns extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'ops:reconcile-adapter-runs
|
||||||
|
{--type= : Adapter run type (e.g. restore.execute)}
|
||||||
|
{--tenant= : Tenant ID}
|
||||||
|
{--older-than=60 : Only consider runs older than N minutes}
|
||||||
|
{--dry-run=true : Preview only (true/false)}
|
||||||
|
{--limit=50 : Max number of runs to inspect}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Reconcile stale adapter-backed operation runs from DB-only source-of-truth records.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
/** @var AdapterRunReconciler $reconciler */
|
||||||
|
$reconciler = app(AdapterRunReconciler::class);
|
||||||
|
|
||||||
|
$type = $this->option('type');
|
||||||
|
$type = is_string($type) && trim($type) !== '' ? trim($type) : null;
|
||||||
|
|
||||||
|
$tenantId = $this->option('tenant');
|
||||||
|
$tenantId = is_numeric($tenantId) ? (int) $tenantId : null;
|
||||||
|
|
||||||
|
$olderThanMinutes = $this->option('older-than');
|
||||||
|
$olderThanMinutes = is_numeric($olderThanMinutes) ? (int) $olderThanMinutes : 60;
|
||||||
|
$olderThanMinutes = max(1, $olderThanMinutes);
|
||||||
|
|
||||||
|
$limit = $this->option('limit');
|
||||||
|
$limit = is_numeric($limit) ? (int) $limit : 50;
|
||||||
|
$limit = max(1, $limit);
|
||||||
|
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$dryRun = filter_var($dryRun, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||||
|
$dryRun = $dryRun ?? true;
|
||||||
|
|
||||||
|
$result = $reconciler->reconcile([
|
||||||
|
'type' => $type,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'older_than_minutes' => $olderThanMinutes,
|
||||||
|
'limit' => $limit,
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$changes = $result['changes'] ?? [];
|
||||||
|
|
||||||
|
usort($changes, static fn (array $a, array $b): int => ((int) ($a['operation_run_id'] ?? 0)) <=> ((int) ($b['operation_run_id'] ?? 0)));
|
||||||
|
|
||||||
|
$this->info('Adapter run reconciliation');
|
||||||
|
$this->line('dry_run: '.($dryRun ? 'true' : 'false'));
|
||||||
|
$this->line('type: '.($type ?? '(all supported)'));
|
||||||
|
$this->line('tenant: '.($tenantId ? (string) $tenantId : '(all)'));
|
||||||
|
$this->line('older_than_minutes: '.$olderThanMinutes);
|
||||||
|
$this->line('limit: '.$limit);
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->line('candidates: '.(int) ($result['candidates'] ?? 0));
|
||||||
|
$this->line('reconciled: '.(int) ($result['reconciled'] ?? 0));
|
||||||
|
$this->line('skipped: '.(int) ($result['skipped'] ?? 0));
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
$this->info('No changes.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($changes as $change) {
|
||||||
|
$before = is_array($change['before'] ?? null) ? $change['before'] : [];
|
||||||
|
$after = is_array($change['after'] ?? null) ? $change['after'] : [];
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'applied' => ($change['applied'] ?? false) ? 'yes' : 'no',
|
||||||
|
'operation_run_id' => (int) ($change['operation_run_id'] ?? 0),
|
||||||
|
'type' => (string) ($change['type'] ?? ''),
|
||||||
|
'source_id' => (int) ($change['restore_run_id'] ?? 0),
|
||||||
|
'before' => (string) (($before['status'] ?? '').'/'.($before['outcome'] ?? '')),
|
||||||
|
'after' => (string) (($after['status'] ?? '').'/'.($after['outcome'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['applied', 'operation_run_id', 'type', 'source_id', 'before', 'after'],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('Reconciliation failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
return Tenant::currentOrFail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal file
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\BackupScheduling\BackupScheduleDispatcher;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TenantpilotDispatchBackupSchedules extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:schedules:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||||
|
|
||||||
|
protected $description = 'Dispatch due backup schedules (idempotent per schedule minute-slot).';
|
||||||
|
|
||||||
|
public function handle(BackupScheduleDispatcher $dispatcher): int
|
||||||
|
{
|
||||||
|
$tenantIdentifiers = (array) $this->option('tenant');
|
||||||
|
|
||||||
|
$result = $dispatcher->dispatchDue($tenantIdentifiers);
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).',
|
||||||
|
$result['scanned_schedules'],
|
||||||
|
$result['created_runs'],
|
||||||
|
$result['skipped_runs'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php
Normal file
116
app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TenantpilotDispatchDirectoryGroupsSync extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:directory-groups:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||||
|
|
||||||
|
protected $description = 'Dispatch scheduled directory group sync runs (idempotent per tenant minute-slot).';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! (bool) config('directory_groups.schedule.enabled', false)) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = CarbonImmutable::now('UTC');
|
||||||
|
$timeUtc = (string) config('directory_groups.schedule.time_utc', '02:00');
|
||||||
|
|
||||||
|
if (! $this->isDueAt($now, $timeUtc)) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists(\App\Jobs\EntraGroupSyncJob::class)) {
|
||||||
|
$this->warn('EntraGroupSyncJob is not available; skipping scheduled directory group sync dispatch.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdentifiers = array_values(array_filter(array_map('strval', array_merge(
|
||||||
|
(array) $this->option('tenant'),
|
||||||
|
(array) config('directory_groups.schedule.tenants', []),
|
||||||
|
))));
|
||||||
|
|
||||||
|
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||||
|
|
||||||
|
$selectionKey = 'groups-v1:all';
|
||||||
|
$slotKey = $now->format('YmdHi').'Z';
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
$inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'slot_key' => $slotKey,
|
||||||
|
'status' => 'pending',
|
||||||
|
'initiator_user_id' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($inserted === 1) {
|
||||||
|
$created++;
|
||||||
|
|
||||||
|
dispatch(new \App\Jobs\EntraGroupSyncJob(
|
||||||
|
tenantId: $tenant->getKey(),
|
||||||
|
selectionKey: $selectionKey,
|
||||||
|
slotKey: $slotKey,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Scanned %d tenant(s), created %d run(s), skipped %d duplicate run(s).',
|
||||||
|
$tenants->count(),
|
||||||
|
$created,
|
||||||
|
$skipped,
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
*/
|
||||||
|
private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$query = Tenant::activeQuery();
|
||||||
|
|
||||||
|
if ($tenantIdentifiers !== []) {
|
||||||
|
$query->where(function ($subQuery) use ($tenantIdentifiers) {
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
if (ctype_digit($identifier)) {
|
||||||
|
$subQuery->orWhereKey((int) $identifier);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subQuery->orWhere('tenant_id', $identifier)
|
||||||
|
->orWhere('external_id', $identifier);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDueAt(CarbonImmutable $now, string $timeUtc): bool
|
||||||
|
{
|
||||||
|
if (! preg_match('/^(?<hour>[01]\\d|2[0-3]):(?<minute>[0-5]\\d)$/', $timeUtc, $matches)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $matches['hour'] === (int) $now->format('H')
|
||||||
|
&& (int) $matches['minute'] === (int) $now->format('i');
|
||||||
|
}
|
||||||
|
}
|
||||||
164
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal file
164
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSchedule;
|
||||||
|
use App\Models\BackupScheduleRun;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class TenantpilotPurgeNonPersistentData extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'tenantpilot:purge-nonpersistent
|
||||||
|
{tenant? : Tenant id / tenant_id / external_id (defaults to current tenant)}
|
||||||
|
{--all : Purge for all tenants}
|
||||||
|
{--force : Actually delete rows}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tenants = $this->resolveTenants();
|
||||||
|
|
||||||
|
if ($tenants->isEmpty()) {
|
||||||
|
$this->error('No tenants selected. Provide {tenant} or use --all.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDryRun = ! (bool) $this->option('force');
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('Dry run: no rows will be deleted. Re-run with --force to apply.');
|
||||||
|
} else {
|
||||||
|
$this->warn('This will PERMANENTLY delete non-persistent tenant data.');
|
||||||
|
|
||||||
|
if ($this->input->isInteractive() && ! $this->confirm('Proceed?', false)) {
|
||||||
|
$this->info('Aborted.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
$counts = $this->countsForTenant($tenant);
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
$this->info("Tenant: {$tenant->id} ({$tenant->name})");
|
||||||
|
$this->table(
|
||||||
|
['Table', 'Rows'],
|
||||||
|
collect($counts)
|
||||||
|
->map(fn (int $count, string $table) => [$table, $count])
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($tenant): void {
|
||||||
|
BackupScheduleRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
BackupSchedule::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
AuditLog::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
RestoreRun::withTrashed()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->forceDelete();
|
||||||
|
|
||||||
|
BackupItem::withTrashed()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->forceDelete();
|
||||||
|
|
||||||
|
BackupSet::withTrashed()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->forceDelete();
|
||||||
|
|
||||||
|
PolicyVersion::withTrashed()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->forceDelete();
|
||||||
|
|
||||||
|
Policy::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info('Purged.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenants()
|
||||||
|
{
|
||||||
|
if ((bool) $this->option('all')) {
|
||||||
|
return Tenant::query()->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantArg = $this->argument('tenant');
|
||||||
|
|
||||||
|
if ($tenantArg !== null && $tenantArg !== '') {
|
||||||
|
$tenant = Tenant::query()->forTenant($tenantArg)->first();
|
||||||
|
|
||||||
|
return $tenant ? collect([$tenant]) : collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return collect([Tenant::currentOrFail()]);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,int>
|
||||||
|
*/
|
||||||
|
private function countsForTenant(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'backup_schedule_runs' => BackupScheduleRun::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(),
|
||||||
|
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
|
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
|
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
|
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
|
'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
|
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\BackupScheduleRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
|
||||||
|
{--tenant=* : Limit to tenant_id/external_id}
|
||||||
|
{--older-than=5 : Only reconcile runs older than N minutes}
|
||||||
|
{--dry-run : Do not write changes}';
|
||||||
|
|
||||||
|
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
|
||||||
|
|
||||||
|
public function handle(OperationRunService $operationRunService): int
|
||||||
|
{
|
||||||
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$query = OperationRun::query()
|
||||||
|
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
||||||
|
->whereIn('status', ['queued', 'running']);
|
||||||
|
|
||||||
|
if ($olderThanMinutes > 0) {
|
||||||
|
$query->where('created_at', '<', now()->subMinutes($olderThanMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantIdentifiers !== []) {
|
||||||
|
$tenantIds = $this->resolveTenantIds($tenantIdentifiers);
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
$this->info('No tenants matched the provided identifiers.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereIn('tenant_id', $tenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($query->cursor() as $operationRun) {
|
||||||
|
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
|
||||||
|
|
||||||
|
if (! is_numeric($backupScheduleRunId)) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduleRun = BackupScheduleRun::query()
|
||||||
|
->whereKey((int) $backupScheduleRunId)
|
||||||
|
->where('tenant_id', $operationRun->tenant_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $scheduleRun) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'backup_schedule_run.not_found',
|
||||||
|
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->updateRun($operationRun, 'running', 'pending');
|
||||||
|
|
||||||
|
if ($scheduleRun->started_at) {
|
||||||
|
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = match ($scheduleRun->status) {
|
||||||
|
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||||
|
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||||
|
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
|
||||||
|
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
||||||
|
default => 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
|
||||||
|
$syncFailures = $summary['sync_failures'] ?? [];
|
||||||
|
|
||||||
|
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||||
|
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||||
|
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
||||||
|
|
||||||
|
$processed = $policiesBackedUp + $syncFailuresCount;
|
||||||
|
if ($policiesTotal > 0) {
|
||||||
|
$processed = min($policiesTotal, $processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryCounts = array_filter([
|
||||||
|
'total' => $policiesTotal,
|
||||||
|
'processed' => $processed,
|
||||||
|
'succeeded' => $policiesBackedUp,
|
||||||
|
'failed' => $syncFailuresCount,
|
||||||
|
'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0,
|
||||||
|
'items' => $policiesTotal,
|
||||||
|
], fn (mixed $value): bool => is_int($value) && $value !== 0);
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) {
|
||||||
|
$failures[] = [
|
||||||
|
'code' => 'backup_schedule_run.cancelled',
|
||||||
|
'message' => 'Backup schedule run was cancelled.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
|
||||||
|
$failures[] = [
|
||||||
|
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
|
||||||
|
'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($syncFailures)) {
|
||||||
|
foreach ($syncFailures as $failure) {
|
||||||
|
if (! is_array($failure)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
||||||
|
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||||
|
$errors = $failure['errors'] ?? null;
|
||||||
|
|
||||||
|
$firstErrorMessage = null;
|
||||||
|
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
||||||
|
$firstErrorMessage = $errors[0]['message'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $status !== null
|
||||||
|
? "{$policyType}: Graph returned {$status}"
|
||||||
|
: "{$policyType}: Graph request failed";
|
||||||
|
|
||||||
|
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
||||||
|
$message .= ' - '.trim($firstErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures[] = [
|
||||||
|
'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
|
||||||
|
'message' => RunFailureSanitizer::sanitizeMessage($message),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRun->update([
|
||||||
|
'context' => array_merge($operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
|
||||||
|
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: $outcome,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
|
||||||
|
$operationRun->forceFill([
|
||||||
|
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
|
||||||
|
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Reconciled %d run(s), skipped %d, failed %d.',
|
||||||
|
$reconciled,
|
||||||
|
$skipped,
|
||||||
|
$failed,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Dry-run: no changes written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->forTenant($identifier)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Exceptions/InvalidPolicyTypeException.php
Normal file
17
app/Exceptions/InvalidPolicyTypeException.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class InvalidPolicyTypeException extends RuntimeException
|
||||||
|
{
|
||||||
|
public array $unknownPolicyTypes;
|
||||||
|
|
||||||
|
public function __construct(array $unknownPolicyTypes)
|
||||||
|
{
|
||||||
|
$this->unknownPolicyTypes = array_values($unknownPolicyTypes);
|
||||||
|
|
||||||
|
parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Clusters/Inventory/InventoryCluster.php
Normal file
16
app/Filament/Clusters/Inventory/InventoryCluster.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\Inventory;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Clusters\Cluster;
|
||||||
|
use Filament\Pages\Enums\SubNavigationPosition;
|
||||||
|
|
||||||
|
class InventoryCluster extends Cluster
|
||||||
|
{
|
||||||
|
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
}
|
||||||
44
app/Filament/Concerns/ScopesGlobalSearchToTenant.php
Normal file
44
app/Filament/Concerns/ScopesGlobalSearchToTenant.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Concerns;
|
||||||
|
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
trait ScopesGlobalSearchToTenant
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The Eloquent relationship name used to scope records to the current tenant.
|
||||||
|
*/
|
||||||
|
protected static string $globalSearchTenantRelationship = 'tenant';
|
||||||
|
|
||||||
|
public static function getGlobalSearchEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$query = static::getModel()::query();
|
||||||
|
|
||||||
|
if (! static::isScopedToTenant()) {
|
||||||
|
$panel = Filament::getCurrentOrDefaultPanel();
|
||||||
|
|
||||||
|
if ($panel?->hasTenancy()) {
|
||||||
|
$query->withoutGlobalScope($panel->getTenancyScopeName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Model) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user || ! method_exists($user, 'canAccessTenant') || ! $user->canAccessTenant($tenant)) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Filament/Pages/Auth/Login.php
Normal file
12
app/Filament/Pages/Auth/Login.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Auth;
|
||||||
|
|
||||||
|
use Filament\Auth\Pages\Login as BaseLogin;
|
||||||
|
|
||||||
|
class Login extends BaseLogin
|
||||||
|
{
|
||||||
|
protected string $view = 'filament.pages.auth.login';
|
||||||
|
}
|
||||||
35
app/Filament/Pages/BreakGlassRecovery.php
Normal file
35
app/Filament/Pages/BreakGlassRecovery.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BreakGlassRecovery extends Page
|
||||||
|
{
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'System';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Break-glass recovery';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 999;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.break-glass-recovery';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Filament/Pages/ChooseTenant.php
Normal file
92
app/Filament/Pages/ChooseTenant.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserTenantPreference;
|
||||||
|
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);
|
||||||
|
|
||||||
|
$this->redirect(TenantDashboard::getUrl(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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/Filament/Pages/ChooseWorkspace.php
Normal file
172
app/Filament/Pages/ChooseWorkspace.php
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class ChooseWorkspace 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-workspace';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Choose workspace';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.choose-workspace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('createWorkspace')
|
||||||
|
->label('Create workspace')
|
||||||
|
->modalHeading('Create workspace')
|
||||||
|
->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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Workspace>
|
||||||
|
*/
|
||||||
|
public function getWorkspaces(): Collection
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return Workspace::query()->whereRaw('1 = 0')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workspace::query()
|
||||||
|
->whereIn('id', function ($query) use ($user): void {
|
||||||
|
$query->from('workspace_memberships')
|
||||||
|
->select('workspace_id')
|
||||||
|
->where('user_id', $user->getKey());
|
||||||
|
})
|
||||||
|
->whereNull('archived_at')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectWorkspace(int $workspaceId): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($workspace->archived_at)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
if (! $context->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$context->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
|
||||||
|
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, slug?: string|null} $data
|
||||||
|
*/
|
||||||
|
public function createWorkspace(array $data): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'slug' => $data['slug'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::query()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace created')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirectAfterWorkspaceSelected(User $user): string
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
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(tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChooseTenant::getUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
284
app/Filament/Pages/DriftLanding.php
Normal file
284
app/Filament/Pages/DriftLanding.php
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
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\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 = InventorySyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||||
|
->whereNotNull('finished_at')
|
||||||
|
->orderByDesc('finished_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $latestSuccessful instanceof InventorySyncRun) {
|
||||||
|
$this->state = 'blocked';
|
||||||
|
$this->message = 'No successful inventory runs found yet.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeKey = (string) $latestSuccessful->selection_hash;
|
||||||
|
$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->finished_at?->toDateTimeString();
|
||||||
|
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
||||||
|
|
||||||
|
$existingOperationRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'drift.generate')
|
||||||
|
->where('context->scope_key', $scopeKey)
|
||||||
|
->where('context->baseline_run_id', (int) $baseline->getKey())
|
||||||
|
->where('context->current_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_run_id', $baseline->getKey())
|
||||||
|
->where('current_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_run_id', $baseline->getKey())
|
||||||
|
->where('current_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_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $opService->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'drift.generate',
|
||||||
|
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_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_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 InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->currentRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOperationRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->operationRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRunLinks::view($this->operationRunId, Tenant::current());
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Filament/Pages/InventoryCoverage.php
Normal file
66
app/Filament/Pages/InventoryCoverage.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
|
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||||
|
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';
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Filament/Pages/InventoryLanding.php
Normal file
38
app/Filament/Pages/InventoryLanding.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class InventoryLanding extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Overview';
|
||||||
|
|
||||||
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.inventory-landing';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
InventoryKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Filament/Pages/Monitoring/Operations.php
Normal file
125
app/Filament/Pages/Monitoring/Operations.php
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class Operations extends Page implements HasForms, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
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 table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(
|
||||||
|
OperationRun::query()
|
||||||
|
->where('tenant_id', Filament::getTenant()->id)
|
||||||
|
->latest('created_at')
|
||||||
|
)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('type')
|
||||||
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||||
|
|
||||||
|
TextColumn::make('outcome')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||||
|
|
||||||
|
TextColumn::make('initiator_name')
|
||||||
|
->label('Initiator')
|
||||||
|
->searchable(),
|
||||||
|
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->label('Started'),
|
||||||
|
|
||||||
|
TextColumn::make('duration')
|
||||||
|
->getStateUsing(function (OperationRun $record) {
|
||||||
|
if ($record->started_at && $record->completed_at) {
|
||||||
|
return $record->completed_at->diffForHumans($record->started_at, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '-';
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('outcome')
|
||||||
|
->options([
|
||||||
|
'succeeded' => 'Succeeded',
|
||||||
|
'partially_succeeded' => 'Partially Succeeded',
|
||||||
|
'failed' => 'Failed',
|
||||||
|
'cancelled' => 'Cancelled',
|
||||||
|
'pending' => 'Pending',
|
||||||
|
]),
|
||||||
|
|
||||||
|
SelectFilter::make('type')
|
||||||
|
->options(
|
||||||
|
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
|
||||||
|
->distinct()
|
||||||
|
->pluck('type', 'type')
|
||||||
|
->toArray()
|
||||||
|
),
|
||||||
|
|
||||||
|
Filter::make('created_at')
|
||||||
|
->form([
|
||||||
|
DatePicker::make('created_from'),
|
||||||
|
DatePicker::make('created_until'),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
return $query
|
||||||
|
->when(
|
||||||
|
$data['created_from'],
|
||||||
|
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$data['created_until'],
|
||||||
|
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
// View action handled by opening a modal or side-peek
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/Filament/Pages/NoAccess.php
Normal file
85
app/Filament/Pages/NoAccess.php
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class NoAccess 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 = 'no-access';
|
||||||
|
|
||||||
|
protected static ?string $title = 'No access';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.no-access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('createWorkspace')
|
||||||
|
->label('Create workspace')
|
||||||
|
->modalHeading('Create workspace')
|
||||||
|
->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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, slug?: string|null} $data
|
||||||
|
*/
|
||||||
|
public function createWorkspace(array $data): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'slug' => $data['slug'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::query()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace created')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(ChooseTenant::getUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Operations;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class TenantlessOperationRunViewer extends Page
|
||||||
|
{
|
||||||
|
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('refresh')
|
||||||
|
->label('Refresh')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => url()->current()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(OperationRun $run): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = (int) ($run->workspace_id ?? 0);
|
||||||
|
|
||||||
|
if ($workspaceId <= 0) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isMember = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isMember) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
app/Filament/Pages/Tenancy/RegisterTenant.php
Normal file
154
app/Filament/Pages/Tenancy/RegisterTenant.php
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Tenancy;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class RegisterTenant extends BaseRegisterTenant
|
||||||
|
{
|
||||||
|
public static function getLabel(): string
|
||||||
|
{
|
||||||
|
return 'Register tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$canRegisterInWorkspace = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->whereIn('role', ['owner', 'manager'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($canRegisterInWorkspace) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||||
|
|
||||||
|
if ($tenantIds->isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||||
|
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Select::make('environment')
|
||||||
|
->options([
|
||||||
|
'prod' => 'PROD',
|
||||||
|
'dev' => 'DEV',
|
||||||
|
'staging' => 'STAGING',
|
||||||
|
'other' => 'Other',
|
||||||
|
])
|
||||||
|
->default('other')
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('tenant_id')
|
||||||
|
->label('Tenant ID (GUID)')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
Forms\Components\TextInput::make('domain')
|
||||||
|
->label('Primary domain')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('app_client_id')
|
||||||
|
->label('App Client ID')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('app_client_secret')
|
||||||
|
->label('App Client Secret')
|
||||||
|
->password()
|
||||||
|
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||||
|
->dehydrated(fn ($state) => filled($state)),
|
||||||
|
Forms\Components\TextInput::make('app_certificate_thumbprint')
|
||||||
|
->label('Certificate thumbprint')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Textarea::make('app_notes')
|
||||||
|
->label('Notes')
|
||||||
|
->rows(3),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
protected function handleRegistration(array $data): Model
|
||||||
|
{
|
||||||
|
if (! static::canView()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$data['workspace_id'] = $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::create($data);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($user instanceof User) {
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => [
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'created_by_user_id' => $user->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'tenant_membership.bootstrap_assign',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Filament/Pages/TenantDashboard.php
Normal file
34
app/Filament/Pages/TenantDashboard.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
|
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||||
|
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||||
|
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||||
|
use Filament\Pages\Dashboard;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
|
|
||||||
|
class TenantDashboard extends Dashboard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||||
|
*/
|
||||||
|
public function getWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DashboardKpis::class,
|
||||||
|
NeedsAttention::class,
|
||||||
|
RecentDriftFindings::class,
|
||||||
|
RecentOperations::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColumns(): int|array
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Filament/Pages/TenantDiagnostics.php
Normal file
108
app/Filament/Pages/TenantDiagnostics.php
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\TenantDiagnosticsService;
|
||||||
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class TenantDiagnostics extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'diagnostics';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.tenant-diagnostics';
|
||||||
|
|
||||||
|
public bool $missingOwner = false;
|
||||||
|
|
||||||
|
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
$this->missingOwner = ! TenantMembership::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('role', 'owner')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hasDuplicateMembershipsForCurrentUser = app(TenantDiagnosticsService::class)
|
||||||
|
->userHasDuplicateMemberships($tenant, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('bootstrapOwner')
|
||||||
|
->label('Bootstrap owner')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn () => $this->bootstrapOwner()),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->destructive()
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply()
|
||||||
|
->visible(fn (): bool => $this->missingOwner),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('mergeDuplicateMemberships')
|
||||||
|
->label('Merge duplicate memberships')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn () => $this->mergeDuplicateMemberships()),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->destructive()
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply()
|
||||||
|
->visible(fn (): bool => $this->hasDuplicateMembershipsForCurrentUser),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bootstrapOwner(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
|
||||||
|
|
||||||
|
$this->mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mergeDuplicateMemberships(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
app(TenantDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
|
||||||
|
|
||||||
|
$this->mount();
|
||||||
|
}
|
||||||
|
}
|
||||||
1640
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
1640
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
File diff suppressed because it is too large
Load Diff
79
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
79
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Workspaces;
|
||||||
|
|
||||||
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class ManagedTenantsLanding extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Managed tenants';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
|
||||||
|
|
||||||
|
public Workspace $workspace;
|
||||||
|
|
||||||
|
public function mount(Workspace $workspace): void
|
||||||
|
{
|
||||||
|
$this->workspace = $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Tenant>
|
||||||
|
*/
|
||||||
|
public function getTenants(): Collection
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->tenants()
|
||||||
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goToChooseTenant(): void
|
||||||
|
{
|
||||||
|
$this->redirect(ChooseTenant::getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openTenant(int $tenantId): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
|
->whereKey($tenantId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
1134
app/Filament/Resources/BackupScheduleResource.php
Normal file
1134
app/Filament/Resources/BackupScheduleResource.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateBackupSchedule extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BackupScheduleResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||||
|
$data = BackupScheduleResource::assignTenant($data);
|
||||||
|
|
||||||
|
return BackupScheduleResource::hydrateNextRun($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditBackupSchedule extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BackupScheduleResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||||
|
|
||||||
|
return BackupScheduleResource::hydrateNextRun($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?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 [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Models\BackupScheduleRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class BackupScheduleRunsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'runs';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
|
||||||
|
->defaultSort('scheduled_for', 'desc')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('scheduled_for')
|
||||||
|
->label('Scheduled for')
|
||||||
|
->dateTime(),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)),
|
||||||
|
Tables\Columns\TextColumn::make('duration')
|
||||||
|
->label('Duration')
|
||||||
|
->getStateUsing(function (BackupScheduleRun $record): string {
|
||||||
|
if (! $record->started_at || ! $record->finished_at) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
|
||||||
|
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return $seconds.'s';
|
||||||
|
}
|
||||||
|
|
||||||
|
$minutes = intdiv($seconds, 60);
|
||||||
|
$rem = $seconds % 60;
|
||||||
|
|
||||||
|
return sprintf('%dm %ds', $minutes, $rem);
|
||||||
|
}),
|
||||||
|
Tables\Columns\TextColumn::make('counts')
|
||||||
|
->label('Counts')
|
||||||
|
->getStateUsing(function (BackupScheduleRun $record): string {
|
||||||
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
|
|
||||||
|
$total = (int) ($summary['policies_total'] ?? 0);
|
||||||
|
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||||
|
$errors = (int) ($summary['errors_count'] ?? 0);
|
||||||
|
|
||||||
|
if ($total === 0 && $backedUp === 0 && $errors === 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
|
||||||
|
}),
|
||||||
|
Tables\Columns\TextColumn::make('error_code')
|
||||||
|
->label('Error')
|
||||||
|
->badge()
|
||||||
|
->default('—'),
|
||||||
|
Tables\Columns\TextColumn::make('error_message')
|
||||||
|
->label('Message')
|
||||||
|
->default('—')
|
||||||
|
->limit(80)
|
||||||
|
->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('backup_set_id')
|
||||||
|
->label('Backup set')
|
||||||
|
->default('—')
|
||||||
|
->url(function (BackupScheduleRun $record): ?string {
|
||||||
|
if (! $record->backup_set_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
|
||||||
|
})
|
||||||
|
->openUrlInNewTab(true),
|
||||||
|
])
|
||||||
|
->filters([])
|
||||||
|
->headerActions([])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view')
|
||||||
|
->label('View')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->modalHeading('View backup schedule run')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Close')
|
||||||
|
->modalContent(function (BackupScheduleRun $record): View {
|
||||||
|
return view('filament.modals.backup-schedule-run-view', [
|
||||||
|
'run' => $record,
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,14 +9,24 @@
|
|||||||
use App\Jobs\BulkBackupSetRestoreJob;
|
use App\Jobs\BulkBackupSetRestoreJob;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -37,6 +47,22 @@ class BackupSetResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
|
public static function canCreate(): 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_SYNC);
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -53,7 +79,12 @@ public static function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')->searchable(),
|
Tables\Columns\TextColumn::make('name')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('status')->badge(),
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||||
Tables\Columns\TextColumn::make('item_count')->label('Items'),
|
Tables\Columns\TextColumn::make('item_count')->label('Items'),
|
||||||
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
|
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
|
||||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
||||||
@ -71,251 +102,356 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('restore')
|
UiEnforcement::forAction(
|
||||||
->label('Restore')
|
Actions\Action::make('restore')
|
||||||
->color('success')
|
->label('Restore')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->visible(fn (BackupSet $record) => $record->trashed())
|
->requiresConfirmation()
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
$record->restore();
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$record->items()->withTrashed()->restore();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if ($record->tenant) {
|
$record->restore();
|
||||||
$auditLogger->log(
|
$record->items()->withTrashed()->restore();
|
||||||
tenant: $record->tenant,
|
|
||||||
action: 'backup.restored',
|
|
||||||
resourceType: 'backup_set',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
if ($record->tenant) {
|
||||||
->title('Backup set restored')
|
$auditLogger->log(
|
||||||
->success()
|
tenant: $record->tenant,
|
||||||
->send();
|
action: 'backup.restored',
|
||||||
}),
|
resourceType: 'backup_set',
|
||||||
Actions\Action::make('archive')
|
resourceId: (string) $record->id,
|
||||||
->label('Archive')
|
status: 'success',
|
||||||
->color('danger')
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
);
|
||||||
->requiresConfirmation()
|
}
|
||||||
->visible(fn (BackupSet $record) => ! $record->trashed())
|
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record->tenant,
|
|
||||||
action: 'backup.deleted',
|
|
||||||
resourceType: 'backup_set',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Backup set archived')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Actions\Action::make('forceDelete')
|
|
||||||
->label('Force delete')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (BackupSet $record) => $record->trashed())
|
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
|
||||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot force delete backup set')
|
->title('Backup set restored')
|
||||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
->success()
|
||||||
->danger()
|
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive')
|
||||||
|
->label('Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||||
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
return;
|
$record->delete();
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup.force_deleted',
|
action: 'backup.deleted',
|
||||||
resourceType: 'backup_set',
|
resourceType: 'backup_set',
|
||||||
resourceId: (string) $record->id,
|
resourceId: (string) $record->id,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$record->items()->withTrashed()->forceDelete();
|
Notification::make()
|
||||||
$record->forceDelete();
|
->title('Backup set archived')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('forceDelete')
|
||||||
|
->label('Force delete')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
Notification::make()
|
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||||
->title('Backup set permanently deleted')
|
Notification::make()
|
||||||
->success()
|
->title('Cannot force delete backup set')
|
||||||
->send();
|
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||||
}),
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.force_deleted',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->items()->withTrashed()->forceDelete();
|
||||||
|
$record->forceDelete();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup set permanently deleted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
BulkAction::make('bulk_delete')
|
UiEnforcement::forBulkAction(
|
||||||
->label('Archive Backup Sets')
|
BulkAction::make('bulk_delete')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->label('Archive Backup Sets')
|
||||||
->color('danger')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->color('danger')
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->requiresConfirmation()
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
return $isOnlyTrashed;
|
return $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
||||||
->form(function (Collection $records) {
|
->form(function (Collection $records) {
|
||||||
if ($records->count() >= 10) {
|
if ($records->count() >= 10) {
|
||||||
return [
|
return [
|
||||||
Forms\Components\TextInput::make('confirmation')
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->label('Type DELETE to confirm')
|
->label('Type DELETE to confirm')
|
||||||
->required()
|
->required()
|
||||||
->in(['DELETE'])
|
->in(['DELETE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type DELETE to confirm.',
|
'in' => 'Please type DELETE to confirm.',
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$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();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($count >= 10) {
|
$initiator = $user instanceof User ? $user : null;
|
||||||
Notification::make()
|
|
||||||
->title('Bulk archive started')
|
/** @var BulkSelectionIdentity $selection */
|
||||||
->body("Archiving {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
->icon('heroicon-o-arrow-path')
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
/** @var OperationRunService $runs */
|
||||||
->duration(8000)
|
$runs = app(OperationRunService::class);
|
||||||
->sendToDatabase($user)
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.delete',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
|
BulkBackupSetDeleteJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
|
backupSetIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'backup_set_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
BulkBackupSetDeleteJob::dispatch($run->id);
|
UiEnforcement::forBulkAction(
|
||||||
} else {
|
BulkAction::make('bulk_restore')
|
||||||
BulkBackupSetDeleteJob::dispatchSync($run->id);
|
->label('Restore Backup Sets')
|
||||||
}
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
})
|
->color('success')
|
||||||
->deselectRecordsAfterCompletion(),
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
BulkAction::make('bulk_restore')
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
->label('Restore Backup Sets')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
return ! $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->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.')
|
||||||
|
->action(function (Collection $records) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
if (! $tenant instanceof Tenant) {
|
||||||
})
|
return;
|
||||||
->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.')
|
|
||||||
->action(function (Collection $records) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
$initiator = $user instanceof User ? $user : null;
|
||||||
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count);
|
|
||||||
|
|
||||||
if ($count >= 10) {
|
/** @var BulkSelectionIdentity $selection */
|
||||||
Notification::make()
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
->title('Bulk restore started')
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
->body("Restoring {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
/** @var OperationRunService $runs */
|
||||||
->iconColor('warning')
|
$runs = app(OperationRunService::class);
|
||||||
->info()
|
|
||||||
->duration(8000)
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
->sendToDatabase($user)
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.restore',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
|
BulkBackupSetRestoreJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
|
backupSetIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'backup_set_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
BulkBackupSetRestoreJob::dispatch($run->id);
|
UiEnforcement::forBulkAction(
|
||||||
} else {
|
BulkAction::make('bulk_force_delete')
|
||||||
BulkBackupSetRestoreJob::dispatchSync($run->id);
|
->label('Force Delete Backup Sets')
|
||||||
}
|
->icon('heroicon-o-trash')
|
||||||
})
|
->color('danger')
|
||||||
->deselectRecordsAfterCompletion(),
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
BulkAction::make('bulk_force_delete')
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
->label('Force Delete Backup Sets')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
return ! $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
||||||
|
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
||||||
|
->form(function (Collection $records) {
|
||||||
|
if ($records->count() >= 10) {
|
||||||
|
return [
|
||||||
|
Forms\Components\TextInput::make('confirmation')
|
||||||
|
->label('Type DELETE to confirm')
|
||||||
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return [];
|
||||||
})
|
})
|
||||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
->action(function (Collection $records) {
|
||||||
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
$tenant = Tenant::current();
|
||||||
->form(function (Collection $records) {
|
$user = auth()->user();
|
||||||
if ($records->count() >= 10) {
|
$count = $records->count();
|
||||||
return [
|
$ids = $records->pluck('id')->toArray();
|
||||||
Forms\Components\TextInput::make('confirmation')
|
|
||||||
->label('Type DELETE to confirm')
|
|
||||||
->required()
|
|
||||||
->in(['DELETE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type DELETE to confirm.',
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
if (! $tenant instanceof Tenant) {
|
||||||
})
|
return;
|
||||||
->action(function (Collection $records) {
|
}
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
$initiator = $user instanceof User ? $user : null;
|
||||||
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count);
|
|
||||||
|
|
||||||
if ($count >= 10) {
|
/** @var BulkSelectionIdentity $selection */
|
||||||
Notification::make()
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
->title('Bulk force delete started')
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
->body("Force deleting {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
/** @var OperationRunService $runs */
|
||||||
->iconColor('warning')
|
$runs = app(OperationRunService::class);
|
||||||
->info()
|
|
||||||
->duration(8000)
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
->sendToDatabase($user)
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.force_delete',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
|
BulkBackupSetForceDeleteJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
|
backupSetIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'backup_set_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
BulkBackupSetForceDeleteJob::dispatch($run->id);
|
->deselectRecordsAfterCompletion(),
|
||||||
} else {
|
)
|
||||||
BulkBackupSetForceDeleteJob::dispatchSync($run->id);
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
}
|
->apply(),
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -325,7 +461,12 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('name'),
|
Infolists\Components\TextEntry::make('name'),
|
||||||
Infolists\Components\TextEntry::make('status')->badge(),
|
Infolists\Components\TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
||||||
|
->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('item_count')->label('Items'),
|
||||||
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
|
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
|
||||||
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,7 +15,7 @@ class ListBackupSets extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,20 @@
|
|||||||
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
@ -18,8 +30,210 @@ class BackupItemsRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
protected static string $relationship = 'items';
|
protected static string $relationship = 'items';
|
||||||
|
|
||||||
|
protected $listeners = [
|
||||||
|
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function closeAddPoliciesModal(): void
|
||||||
|
{
|
||||||
|
$this->unmountAction();
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$refreshTable = Actions\Action::make('refreshTable')
|
||||||
|
->label('Refresh')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->resetTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
$addPolicies = Actions\Action::make('addPolicies')
|
||||||
|
->label('Add Policies')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->tooltip('You do not have permission to add policies.')
|
||||||
|
->modalHeading('Add Policies')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Close')
|
||||||
|
->modalContent(function (): View {
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
return view('filament.modals.backup-set-policy-picker', [
|
||||||
|
'backupSetId' => $backupSet->getKey(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($addPolicies)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to add policies.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$removeItem = Actions\Action::make('remove')
|
||||||
|
->label('Remove')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (BackupItem $record): void {
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItemIds = [(int) $record->getKey()];
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.remove_policies',
|
||||||
|
inputs: [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'backup_item_ids' => $backupItemIds,
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Removal already queued')
|
||||||
|
->body('A matching remove operation is already queued or running.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
backupItemIds: $backupItemIds,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($removeItem)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to remove policies.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$bulkRemove = Actions\BulkAction::make('bulk_remove')
|
||||||
|
->label('Remove selected')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->deselectRecordsAfterCompletion()
|
||||||
|
->action(function (Collection $records): void {
|
||||||
|
if ($records->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItemIds = $records
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($backupItemIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.remove_policies',
|
||||||
|
inputs: [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'backup_item_ids' => $backupItemIds,
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Removal already queued')
|
||||||
|
->body('A matching remove operation is already queued or running.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
backupItemIds: $backupItemIds,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkRemove)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to remove policies.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||||
->columns([
|
->columns([
|
||||||
@ -36,21 +250,31 @@ public function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state) => static::typeMeta($state)['label'] ?? $state),
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
Tables\Columns\TextColumn::make('restore_mode')
|
Tables\Columns\TextColumn::make('restore_mode')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||||
->color(fn (?string $state) => $state === 'preview-only' ? 'warning' : 'success'),
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||||
Tables\Columns\TextColumn::make('risk')
|
Tables\Columns\TextColumn::make('risk')
|
||||||
->label('Risk')
|
->label('Risk')
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a')
|
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a')
|
||||||
->color(fn (?string $state) => str_contains((string) $state, 'high') ? 'danger' : 'gray'),
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||||
|
->icon(BadgeRenderer::icon(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(),
|
||||||
Tables\Columns\TextColumn::make('platform')->badge(),
|
Tables\Columns\TextColumn::make('platform')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
Tables\Columns\TextColumn::make('assignments')
|
Tables\Columns\TextColumn::make('assignments')
|
||||||
->label('Assignments')
|
->label('Assignments')
|
||||||
->badge()
|
->badge()
|
||||||
@ -94,103 +318,30 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Actions\Action::make('addPolicies')
|
$refreshTable,
|
||||||
->label('Add Policies')
|
$addPolicies,
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->modalHeading('Add Policies')
|
|
||||||
->modalSubmitAction(false)
|
|
||||||
->modalCancelActionLabel('Close')
|
|
||||||
->modalContent(function (): View {
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
return view('filament.modals.backup-set-policy-picker', [
|
|
||||||
'backupSetId' => $backupSet->getKey(),
|
|
||||||
]);
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
->label('View policy')
|
->label('View policy')
|
||||||
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null)
|
->url(function (BackupItem $record): ?string {
|
||||||
->hidden(fn ($record) => ! $record->policy_id)
|
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),
|
->openUrlInNewTab(true),
|
||||||
Actions\Action::make('remove')
|
$removeItem,
|
||||||
->label('Remove')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (BackupItem $record, AuditLogger $auditLogger) {
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
if ($record->backupSet) {
|
|
||||||
$record->backupSet->update([
|
|
||||||
'item_count' => $record->backupSet->items()->count(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record->tenant,
|
|
||||||
action: 'backup.item_removed',
|
|
||||||
resourceType: 'backup_set',
|
|
||||||
resourceId: (string) $record->backup_set_id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['policy_id' => $record->policy_id]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy removed from backup')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkActionGroup::make([
|
Actions\BulkActionGroup::make([
|
||||||
Actions\BulkAction::make('bulk_remove')
|
$bulkRemove,
|
||||||
->label('Remove selected')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (Collection $records, AuditLogger $auditLogger) {
|
|
||||||
if ($records->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
$records->each(fn (BackupItem $record) => $record->delete());
|
|
||||||
|
|
||||||
$backupSet->update([
|
|
||||||
'item_count' => $backupSet->items()->count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant = $records->first()?->tenant;
|
|
||||||
|
|
||||||
if ($tenant) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'backup.items_removed',
|
|
||||||
resourceType: 'backup_set',
|
|
||||||
resourceId: (string) $backupSet->id,
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'removed_count' => $records->count(),
|
|
||||||
'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(),
|
|
||||||
'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(),
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Policies removed from backup')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
225
app/Filament/Resources/EntraGroupResource.php
Normal file
225
app/Filament/Resources/EntraGroupResource.php
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<?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 BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\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 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));
|
||||||
|
})
|
||||||
|
->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')->since()->label('Last seen'),
|
||||||
|
])
|
||||||
|
->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([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
|
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||||
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
|
use App\Models\EntraGroupSyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListEntraGroups extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = EntraGroupResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('view_group_sync_runs')
|
||||||
|
->label('Group Sync Runs')
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
||||||
|
->visible(fn (): bool => (bool) Tenant::current()),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('sync_groups')
|
||||||
|
->label('Sync Groups')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'directory_groups.sync',
|
||||||
|
inputs: ['selection_key' => $selectionKey],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Group sync 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;
|
||||||
|
}
|
||||||
|
// ----------------------------------------------
|
||||||
|
|
||||||
|
$existing = EntraGroupSyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('selection_key', $selectionKey)
|
||||||
|
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof EntraGroupSyncRun) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Group sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = EntraGroupSyncRun::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'slot_key' => null,
|
||||||
|
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||||
|
'initiator_user_id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dispatch(new EntraGroupSyncJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
selectionKey: $selectionKey,
|
||||||
|
slotKey: null,
|
||||||
|
runId: (int) $run->getKey(),
|
||||||
|
operationRun: $opRun
|
||||||
|
));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Group sync started')
|
||||||
|
->body('Sync dispatched.')
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to sync groups.')
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
154
app/Filament/Resources/EntraGroupSyncRunResource.php
Normal file
154
app/Filament/Resources/EntraGroupSyncRunResource.php
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||||
|
use App\Models\EntraGroupSyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class EntraGroupSyncRunResource extends Resource
|
||||||
|
{
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = EntraGroupSyncRun::class;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Group Sync Runs';
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Legacy run view')
|
||||||
|
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('canonical_view')
|
||||||
|
->label('Canonical view')
|
||||||
|
->state('View in Operations')
|
||||||
|
->url(fn (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||||
|
->badge()
|
||||||
|
->color('primary'),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Sync Run')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('initiator.name')
|
||||||
|
->label('Initiator')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
|
||||||
|
TextEntry::make('selection_key')->label('Selection'),
|
||||||
|
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
|
||||||
|
TextEntry::make('started_at')->dateTime(),
|
||||||
|
TextEntry::make('finished_at')->dateTime(),
|
||||||
|
TextEntry::make('pages_fetched')->label('Pages')->numeric(),
|
||||||
|
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
|
||||||
|
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||||
|
TextEntry::make('error_count')->label('Errors')->numeric(),
|
||||||
|
TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(),
|
||||||
|
TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Error Summary')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('error_code')->placeholder('—'),
|
||||||
|
TextEntry::make('error_category')->placeholder('—'),
|
||||||
|
ViewEntry::make('error_summary')
|
||||||
|
->label('Safe error summary')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('id', 'desc')
|
||||||
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||||
|
})
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('initiator.name')
|
||||||
|
->label('Initiator')
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
|
||||||
|
Tables\Columns\TextColumn::make('selection_key')
|
||||||
|
->label('Selection')
|
||||||
|
->limit(24)
|
||||||
|
->copyable(),
|
||||||
|
Tables\Columns\TextColumn::make('slot_key')
|
||||||
|
->label('Slot')
|
||||||
|
->placeholder('—')
|
||||||
|
->limit(16)
|
||||||
|
->copyable(),
|
||||||
|
Tables\Columns\TextColumn::make('started_at')->since(),
|
||||||
|
Tables\Columns\TextColumn::make('finished_at')->since(),
|
||||||
|
Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(),
|
||||||
|
Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(),
|
||||||
|
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||||
|
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with('initiator')
|
||||||
|
->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListEntraGroupSyncRuns::route('/'),
|
||||||
|
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||||
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
|
use App\Models\EntraGroupSyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListEntraGroupSyncRuns extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = EntraGroupSyncRunResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('sync_groups')
|
||||||
|
->label('Sync Groups')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
|
$existing = EntraGroupSyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('selection_key', $selectionKey)
|
||||||
|
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof EntraGroupSyncRun) {
|
||||||
|
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
||||||
|
|
||||||
|
$user->notify(new RunStatusChangedNotification([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'run_type' => 'directory_groups',
|
||||||
|
'run_id' => (int) $existing->getKey(),
|
||||||
|
'status' => $normalizedStatus,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = EntraGroupSyncRun::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'slot_key' => null,
|
||||||
|
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||||
|
'initiator_user_id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dispatch(new EntraGroupSyncJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
selectionKey: $selectionKey,
|
||||||
|
slotKey: null,
|
||||||
|
runId: (int) $run->getKey(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$user->notify(new RunStatusChangedNotification([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'run_type' => 'directory_groups',
|
||||||
|
'run_id' => (int) $run->getKey(),
|
||||||
|
'status' => 'queued',
|
||||||
|
]));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewEntraGroupSyncRun extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = EntraGroupSyncRunResource::class;
|
||||||
|
}
|
||||||
445
app/Filament/Resources/FindingResource.php
Normal file
445
app/Filament/Resources/FindingResource.php
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
<?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 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|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 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_run_id')
|
||||||
|
->label('Baseline run')
|
||||||
|
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
||||||
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
||||||
|
: null)
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('current_run_id')
|
||||||
|
->label('Current run')
|
||||||
|
->url(fn (Finding $record): ?string => $record->current_run_id
|
||||||
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
||||||
|
: 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_run_id')
|
||||||
|
->label('Baseline run id')
|
||||||
|
->numeric(),
|
||||||
|
TextInput::make('current_run_id')
|
||||||
|
->label('Current run id')
|
||||||
|
->numeric(),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$baselineRunId = $data['baseline_run_id'] ?? null;
|
||||||
|
if (is_numeric($baselineRunId)) {
|
||||||
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentRunId = $data['current_run_id'] ?? null;
|
||||||
|
if (is_numeric($currentRunId)) {
|
||||||
|
$query->where('current_run_id', (int) $currentRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('acknowledge')
|
||||||
|
->label('Acknowledge')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
|
||||||
|
->authorize(function (Finding $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('update', $record);
|
||||||
|
})
|
||||||
|
->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();
|
||||||
|
}),
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/Filament/Resources/FindingResource/Pages/ListFindings.php
Normal file
153
app/Filament/Resources/FindingResource/Pages/ListFindings.php
Normal 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_run_id');
|
||||||
|
if (is_numeric($baselineRunId)) {
|
||||||
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
||||||
|
if (is_numeric($currentRunId)) {
|
||||||
|
$query->where('current_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal 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;
|
||||||
|
}
|
||||||
307
app/Filament/Resources/InventoryItemResource.php
Normal file
307
app/Filament/Resources/InventoryItemResource.php
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
|
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Enums\RelationshipType;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class InventoryItemResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = InventoryItem::class;
|
||||||
|
|
||||||
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $capabilityResolver->isMember($user, $tenant)
|
||||||
|
&& $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $capabilityResolver->isMember($user, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof InventoryItem) {
|
||||||
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Inventory Item')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('display_name')->label('Name'),
|
||||||
|
TextEntry::make('policy_type')
|
||||||
|
->label('Type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
|
TextEntry::make('category')
|
||||||
|
->badge()
|
||||||
|
->state(fn (InventoryItem $record): ?string => $record->category
|
||||||
|
?: (static::typeMeta($record->policy_type)['category'] ?? null))
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||||
|
TextEntry::make('platform')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
|
TextEntry::make('external_id')->label('External ID'),
|
||||||
|
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
|
||||||
|
TextEntry::make('last_seen_run_id')
|
||||||
|
->label('Last sync run')
|
||||||
|
->url(function (InventoryItem $record): ?string {
|
||||||
|
if (! $record->last_seen_run_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current());
|
||||||
|
})
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('support_restore')
|
||||||
|
->label('Restore')
|
||||||
|
->badge()
|
||||||
|
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||||
|
TextEntry::make('support_risk')
|
||||||
|
->label('Risk')
|
||||||
|
->badge()
|
||||||
|
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal')
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Dependencies')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('dependencies')
|
||||||
|
->label('')
|
||||||
|
->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(),
|
||||||
|
|
||||||
|
Section::make('Metadata (Safe Subset)')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('meta_jsonb')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (InventoryItem $record) => $record->meta_jsonb ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
$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())
|
||||||
|
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
|
||||||
|
->filter(fn ($value, $key) => is_string($key) && $key !== '')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $table
|
||||||
|
->defaultSort('last_seen_at', 'desc')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
|
->label('Name')
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
|
->label('Type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
|
Tables\Columns\TextColumn::make('category')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||||
|
Tables\Columns\TextColumn::make('platform')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
|
Tables\Columns\TextColumn::make('last_seen_at')
|
||||||
|
->label('Last seen')
|
||||||
|
->since(),
|
||||||
|
Tables\Columns\TextColumn::make('lastSeenRun.status')
|
||||||
|
->label('Run')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(function (?string $state): string {
|
||||||
|
if (! filled($state)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label;
|
||||||
|
})
|
||||||
|
->color(function (?string $state): string {
|
||||||
|
if (! filled($state)) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color;
|
||||||
|
})
|
||||||
|
->icon(function (?string $state): ?string {
|
||||||
|
if (! filled($state)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon;
|
||||||
|
})
|
||||||
|
->iconColor(function (?string $state): ?string {
|
||||||
|
if (! filled($state)) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state);
|
||||||
|
|
||||||
|
return $spec->iconColor ?? $spec->color;
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('policy_type')
|
||||||
|
->options($typeOptions)
|
||||||
|
->searchable(),
|
||||||
|
Tables\Filters\SelectFilter::make('category')
|
||||||
|
->options($categoryOptions)
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
->with('lastSeenRun');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListInventoryItems::route('/'),
|
||||||
|
'view' => Pages\ViewInventoryItem::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function typeMeta(?string $type): array
|
||||||
|
{
|
||||||
|
if ($type === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventoryPolicyTypeMeta::metaFor($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function allTypeMeta(): array
|
||||||
|
{
|
||||||
|
return InventoryPolicyTypeMeta::all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
|
use App\Jobs\RunInventorySyncJob;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Inventory\InventorySyncService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Action as HintAction;
|
||||||
|
use Filament\Forms\Components\Hidden;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Support\Enums\Size;
|
||||||
|
|
||||||
|
class ListInventoryItems extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
InventoryKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('run_inventory_sync')
|
||||||
|
->label('Run Inventory Sync')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->form([
|
||||||
|
Select::make('policy_types')
|
||||||
|
->label('Policy types')
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->native(false)
|
||||||
|
->hintActions([
|
||||||
|
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
|
||||||
|
->label('Select all')
|
||||||
|
->link()
|
||||||
|
->size(Size::Small)
|
||||||
|
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
|
||||||
|
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
|
||||||
|
}),
|
||||||
|
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
|
||||||
|
->label('Clear')
|
||||||
|
->link()
|
||||||
|
->size(Size::Small)
|
||||||
|
->action(function () use ($component): void {
|
||||||
|
$component->state([]);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->options(function (): array {
|
||||||
|
return collect(InventoryPolicyTypeMeta::supported())
|
||||||
|
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
|
||||||
|
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
|
||||||
|
->mapWithKeys(function ($items, string $category): array {
|
||||||
|
$options = collect($items)
|
||||||
|
->mapWithKeys(function (array $meta): array {
|
||||||
|
$type = (string) $meta['type'];
|
||||||
|
$label = (string) ($meta['label'] ?? $type);
|
||||||
|
$platform = (string) ($meta['platform'] ?? 'all');
|
||||||
|
|
||||||
|
return [$type => "{$label} • {$platform}"];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [$category => $options];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->columnSpanFull(),
|
||||||
|
Toggle::make('include_foundations')
|
||||||
|
->label('Include foundation types')
|
||||||
|
->helperText('Include scope tags, assignment filters, and notification templates.')
|
||||||
|
->default(true)
|
||||||
|
->dehydrated()
|
||||||
|
->rules(['boolean'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Toggle::make('include_dependencies')
|
||||||
|
->label('Include dependencies')
|
||||||
|
->helperText('Include dependency extraction where supported.')
|
||||||
|
->default(true)
|
||||||
|
->dehydrated()
|
||||||
|
->rules(['boolean'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Hidden::make('tenant_id')
|
||||||
|
->default(fn (): ?string => Tenant::current()?->getKey())
|
||||||
|
->dehydrated(),
|
||||||
|
])
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant);
|
||||||
|
})
|
||||||
|
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedTenantId = $data['tenant_id'] ?? null;
|
||||||
|
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Not allowed')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
||||||
|
if (array_key_exists('policy_types', $data)) {
|
||||||
|
$selectionPayload['policy_types'] = $data['policy_types'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('include_foundations', $data)) {
|
||||||
|
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('include_dependencies', $data)) {
|
||||||
|
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
|
||||||
|
}
|
||||||
|
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'inventory.sync',
|
||||||
|
inputs: $computed['selection'],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Inventory sync 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();
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
|
||||||
|
$existing = InventorySyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('selection_hash', $computed['selection_hash'])
|
||||||
|
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
|
||||||
|
if ($existing instanceof InventorySyncRun) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Inventory sync already active')
|
||||||
|
->body('A matching inventory sync run is already pending or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
|
||||||
|
|
||||||
|
$policyTypes = $computed['selection']['policy_types'] ?? [];
|
||||||
|
if (! is_array($policyTypes)) {
|
||||||
|
$policyTypes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'inventory.sync.dispatched',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'inventory_sync_run_id' => $run->id,
|
||||||
|
'selection_hash' => $run->selection_hash,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $user->id,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
resourceType: 'inventory_sync_run',
|
||||||
|
resourceId: (string) $run->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
|
||||||
|
RunInventorySyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
inventorySyncRunId: (int) $run->id,
|
||||||
|
operationRun: $opRun
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewInventoryItem extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
}
|
||||||
214
app/Filament/Resources/InventorySyncRunResource.php
Normal file
214
app/Filament/Resources/InventorySyncRunResource.php
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class InventorySyncRunResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = InventorySyncRun::class;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = true;
|
||||||
|
|
||||||
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function canViewAny(): 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->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof InventorySyncRun) {
|
||||||
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return 'Sync History';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Legacy run view')
|
||||||
|
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('canonical_view')
|
||||||
|
->label('Canonical view')
|
||||||
|
->state('View in Operations')
|
||||||
|
->url(fn (InventorySyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||||
|
->badge()
|
||||||
|
->color('primary'),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Sync Run')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('user.name')
|
||||||
|
->label('Initiator')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
|
||||||
|
TextEntry::make('selection_hash')->label('Selection hash')->copyable(),
|
||||||
|
TextEntry::make('started_at')->dateTime(),
|
||||||
|
TextEntry::make('finished_at')->dateTime(),
|
||||||
|
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
|
||||||
|
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||||
|
TextEntry::make('errors_count')->label('Errors')->numeric(),
|
||||||
|
TextEntry::make('had_errors')
|
||||||
|
->label('Had errors')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanHasErrors))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::BooleanHasErrors))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::BooleanHasErrors))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanHasErrors)),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Selection Payload')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('selection_payload')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (InventorySyncRun $record) => $record->selection_payload ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Error Summary')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('error_codes')
|
||||||
|
->label('Error codes')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (InventorySyncRun $record) => $record->error_codes ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
ViewEntry::make('error_context')
|
||||||
|
->label('Safe error context')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (InventorySyncRun $record) => $record->error_context ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('id', 'desc')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
|
->label('Initiator')
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
|
||||||
|
Tables\Columns\TextColumn::make('selection_hash')
|
||||||
|
->label('Selection')
|
||||||
|
->copyable()
|
||||||
|
->limit(12),
|
||||||
|
Tables\Columns\TextColumn::make('started_at')->since(),
|
||||||
|
Tables\Columns\TextColumn::make('finished_at')->since(),
|
||||||
|
Tables\Columns\TextColumn::make('items_observed_count')
|
||||||
|
->label('Observed')
|
||||||
|
->numeric(),
|
||||||
|
Tables\Columns\TextColumn::make('items_upserted_count')
|
||||||
|
->label('Upserted')
|
||||||
|
->numeric(),
|
||||||
|
Tables\Columns\TextColumn::make('errors_count')
|
||||||
|
->label('Errors')
|
||||||
|
->numeric(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with('user')
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListInventorySyncRuns::route('/'),
|
||||||
|
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListInventorySyncRuns extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = InventorySyncRunResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
InventoryKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewInventorySyncRun extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = InventorySyncRunResource::class;
|
||||||
|
}
|
||||||
334
app/Filament/Resources/OperationRunResource.php
Normal file
334
app/Filament/Resources/OperationRunResource.php
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
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 string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Operations';
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with('user')
|
||||||
|
->latest('id')
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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('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))
|
||||||
|
->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('type')
|
||||||
|
->options(function (): array {
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
if (! $tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->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 {
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
if (! $tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::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(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListOperationRuns::route('/'),
|
||||||
|
'view' => Pages\ViewOperationRun::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListOperationRuns extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = OperationRunResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
OperationsKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'all' => Tab::make(),
|
||||||
|
'active' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])),
|
||||||
|
'succeeded' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Succeeded->value)),
|
||||||
|
'partial' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
|
||||||
|
'failed' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Failed->value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTablePollingInterval(): ?string
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ViewOperationRun extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OperationRunResource::class;
|
||||||
|
|
||||||
|
public bool $opsUxIsTabHidden = false;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $this->getRecord();
|
||||||
|
|
||||||
|
$related = OperationRunLinks::related($run, $tenant);
|
||||||
|
|
||||||
|
$actions = [];
|
||||||
|
|
||||||
|
foreach ($related as $label => $url) {
|
||||||
|
$actions[] = Actions\Action::make(Str::slug($label, '_'))
|
||||||
|
->label($label)
|
||||||
|
->url($url)
|
||||||
|
->openUrlInNewTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($actions)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Actions\ActionGroup::make($actions)
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,12 +6,23 @@
|
|||||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
use App\Jobs\BulkPolicyDeleteJob;
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
use App\Jobs\BulkPolicyExportJob;
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
use App\Jobs\BulkPolicySyncJob;
|
|
||||||
use App\Jobs\BulkPolicyUnignoreJob;
|
use App\Jobs\BulkPolicyUnignoreJob;
|
||||||
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Models\User;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -61,8 +72,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('latest_snapshot_mode')
|
TextEntry::make('latest_snapshot_mode')
|
||||||
->label('Snapshot')
|
->label('Snapshot')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success')
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||||
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full')
|
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode))
|
||||||
|
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata_only' : 'full')
|
||||||
->helperText(function (Policy $record): ?string {
|
->helperText(function (Policy $record): ?string {
|
||||||
$meta = static::latestVersionMetadata($record);
|
$meta = static::latestVersionMetadata($record);
|
||||||
|
|
||||||
@ -231,17 +245,28 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state, Policy $record) => static::typeMeta($record->policy_type)['label'] ?? $state),
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
|
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||||
|
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
||||||
Tables\Columns\TextColumn::make('category')
|
Tables\Columns\TextColumn::make('category')
|
||||||
->label('Category')
|
->label('Category')
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? 'Unknown'),
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||||
Tables\Columns\TextColumn::make('restore_mode')
|
Tables\Columns\TextColumn::make('restore_mode')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||||
Tables\Columns\TextColumn::make('platform')
|
Tables\Columns\TextColumn::make('platform')
|
||||||
->badge()
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('settings_status')
|
Tables\Columns\TextColumn::make('settings_status')
|
||||||
->label('Settings')
|
->label('Settings')
|
||||||
@ -338,243 +363,538 @@ public static function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('ignore')
|
UiEnforcement::forTableAction(
|
||||||
->label('Ignore')
|
Actions\Action::make('ignore')
|
||||||
->icon('heroicon-o-trash')
|
->label('Ignore')
|
||||||
->color('danger')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->color('danger')
|
||||||
->visible(fn (Policy $record) => $record->ignored_at === null)
|
->requiresConfirmation()
|
||||||
->action(function (Policy $record) {
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
$record->ignore();
|
->action(function (Policy $record): void {
|
||||||
|
$record->ignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy ignored')
|
->title('Policy ignored')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('restore')
|
fn () => Tenant::current(),
|
||||||
->label('Restore')
|
)
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->color('success')
|
->tooltip('You do not have permission to ignore policies.')
|
||||||
->requiresConfirmation()
|
->preserveVisibility()
|
||||||
->visible(fn (Policy $record) => $record->ignored_at !== null)
|
->apply(),
|
||||||
->action(function (Policy $record) {
|
UiEnforcement::forTableAction(
|
||||||
$record->unignore();
|
Actions\Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
||||||
|
->action(function (Policy $record): void {
|
||||||
|
$record->unignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy restored')
|
->title('Policy restored')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('sync')
|
fn () => Tenant::current(),
|
||||||
->label('Sync')
|
)
|
||||||
->icon('heroicon-o-arrow-path')
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->color('primary')
|
->tooltip('You do not have permission to restore policies.')
|
||||||
->requiresConfirmation()
|
->preserveVisibility()
|
||||||
->visible(fn (Policy $record) => $record->ignored_at === null)
|
->apply(),
|
||||||
->action(function (Policy $record) {
|
UiEnforcement::forTableAction(
|
||||||
$tenant = Tenant::current();
|
Actions\Action::make('sync')
|
||||||
$user = auth()->user();
|
->label('Sync')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->action(function (Policy $record, HasTable $livewire): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1);
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
BulkPolicySyncJob::dispatchSync($run->id);
|
if (! $user instanceof User) {
|
||||||
}),
|
abort(403);
|
||||||
Actions\Action::make('export')
|
}
|
||||||
->label('Export to Backup')
|
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
|
||||||
->visible(fn (Policy $record) => $record->ignored_at === null)
|
|
||||||
->form([
|
|
||||||
Forms\Components\TextInput::make('backup_name')
|
|
||||||
->label('Backup Name')
|
|
||||||
->required()
|
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
|
||||||
])
|
|
||||||
->action(function (Policy $record, array $data) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
/** @var OperationRunService $opService */
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync_one',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'one',
|
||||||
|
'policy_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun);
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('export')
|
||||||
|
->label('Export to Backup')
|
||||||
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->form([
|
||||||
|
Forms\Components\TextInput::make('backup_name')
|
||||||
|
->label('Backup Name')
|
||||||
|
->required()
|
||||||
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
|
])
|
||||||
|
->action(function (Policy $record, array $data): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [(int) $record->getKey()];
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.export',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
|
||||||
|
BulkPolicyExportJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
backupName: (string) $data['backup_name'],
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'backup_name' => (string) $data['backup_name'],
|
||||||
|
'policy_count' => 1,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
BulkAction::make('bulk_delete')
|
UiEnforcement::forBulkAction(
|
||||||
->label('Ignore Policies')
|
BulkAction::make('bulk_delete')
|
||||||
->icon('heroicon-o-trash')
|
->label('Ignore Policies')
|
||||||
->color('danger')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->color('danger')
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->requiresConfirmation()
|
||||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$value = $visibilityFilterState['value'] ?? null;
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
return $value === 'ignored';
|
return $value === 'ignored';
|
||||||
})
|
})
|
||||||
->form(function (Collection $records) {
|
->form(function (Collection $records) {
|
||||||
if ($records->count() >= 20) {
|
if ($records->count() >= 20) {
|
||||||
return [
|
return [
|
||||||
Forms\Components\TextInput::make('confirmation')
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->label('Type DELETE to confirm')
|
->label('Type DELETE to confirm')
|
||||||
->required()
|
->required()
|
||||||
->in(['DELETE'])
|
->in(['DELETE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type DELETE to confirm.',
|
'in' => 'Please type DELETE to confirm.',
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, array $data) {
|
->action(function (Collection $records): void {
|
||||||
$tenant = Tenant::current();
|
$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();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.delete',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
|
||||||
|
BulkPolicyDeleteJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'policy_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
if ($count >= 20) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Bulk delete started')
|
->title('Policy delete queued')
|
||||||
->body("Deleting {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
->body("Queued deletion for {$count} policies.")
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->iconColor('warning')
|
->iconColor('warning')
|
||||||
->info()
|
->info()
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->duration(8000)
|
->duration(8000)
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
BulkPolicyDeleteJob::dispatch($run->id);
|
UiEnforcement::forBulkAction(
|
||||||
} else {
|
BulkAction::make('bulk_restore')
|
||||||
BulkPolicyDeleteJob::dispatchSync($run->id);
|
->label('Restore Policies')
|
||||||
}
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
})
|
->color('success')
|
||||||
->deselectRecordsAfterCompletion(),
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
BulkAction::make('bulk_restore')
|
return ! in_array($value, [null, 'ignored'], true);
|
||||||
->label('Restore Policies')
|
})
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
->color('success')
|
$tenant = Tenant::current();
|
||||||
->requiresConfirmation()
|
$user = auth()->user();
|
||||||
->hidden(function (HasTable $livewire): bool {
|
$count = $records->count();
|
||||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
$ids = $records->pluck('id')->toArray();
|
||||||
$value = $visibilityFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
return ! in_array($value, [null, 'ignored'], true);
|
if (! $tenant instanceof Tenant) {
|
||||||
})
|
abort(404);
|
||||||
->action(function (Collection $records) {
|
}
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if ($count >= 20) {
|
/** @var BulkSelectionIdentity $selection */
|
||||||
Notification::make()
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
->title('Bulk restore started')
|
|
||||||
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
/** @var OperationRunService $runs */
|
||||||
->info()
|
$runs = app(OperationRunService::class);
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.unignore',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
||||||
|
if ($count >= 20) {
|
||||||
|
BulkPolicyUnignoreJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BulkPolicyUnignoreJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'policy_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk restore started')
|
||||||
|
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->iconColor('warning')
|
||||||
|
->info()
|
||||||
|
->duration(8000)
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
BulkPolicyUnignoreJob::dispatch($run->id);
|
UiEnforcement::forBulkAction(
|
||||||
} else {
|
BulkAction::make('bulk_sync')
|
||||||
BulkPolicyUnignoreJob::dispatchSync($run->id);
|
->label('Sync Policies')
|
||||||
}
|
->icon('heroicon-o-arrow-path')
|
||||||
})
|
->color('primary')
|
||||||
->deselectRecordsAfterCompletion(),
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
BulkAction::make('bulk_sync')
|
return $value === 'ignored';
|
||||||
->label('Sync Policies')
|
})
|
||||||
->icon('heroicon-o-arrow-path')
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
->color('primary')
|
$tenant = Tenant::current();
|
||||||
->requiresConfirmation()
|
$user = auth()->user();
|
||||||
->hidden(function (HasTable $livewire): bool {
|
$count = $records->count();
|
||||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
|
||||||
$value = $visibilityFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
return $value === 'ignored';
|
if (! $tenant instanceof Tenant) {
|
||||||
})
|
abort(404);
|
||||||
->action(function (Collection $records) {
|
}
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if ($count >= 20) {
|
$ids = $records
|
||||||
Notification::make()
|
->pluck('id')
|
||||||
->title('Bulk sync started')
|
->map(static fn ($id): int => (int) $id)
|
||||||
->body("Syncing {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
->unique()
|
||||||
->icon('heroicon-o-arrow-path')
|
->sort()
|
||||||
->iconColor('warning')
|
->values()
|
||||||
->info()
|
->all();
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'subset',
|
||||||
|
'policy_ids' => $ids,
|
||||||
|
],
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun);
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
BulkPolicySyncJob::dispatch($run->id);
|
UiEnforcement::forBulkAction(
|
||||||
} else {
|
BulkAction::make('bulk_export')
|
||||||
BulkPolicySyncJob::dispatchSync($run->id);
|
->label('Export to Backup')
|
||||||
}
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
})
|
->form([
|
||||||
->deselectRecordsAfterCompletion(),
|
Forms\Components\TextInput::make('backup_name')
|
||||||
|
->label('Backup Name')
|
||||||
|
->required()
|
||||||
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
BulkAction::make('bulk_export')
|
if (! $tenant instanceof Tenant) {
|
||||||
->label('Export to Backup')
|
abort(404);
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
}
|
||||||
->form([
|
|
||||||
Forms\Components\TextInput::make('backup_name')
|
|
||||||
->label('Backup Name')
|
|
||||||
->required()
|
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
|
||||||
])
|
|
||||||
->action(function (Collection $records, array $data) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if ($count >= 20) {
|
/** @var BulkSelectionIdentity $selection */
|
||||||
Notification::make()
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
->title('Bulk export started')
|
|
||||||
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
/** @var OperationRunService $runs */
|
||||||
->info()
|
$runs = app(OperationRunService::class);
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.export',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
||||||
|
if ($count >= 20) {
|
||||||
|
BulkPolicyExportJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
backupName: (string) $data['backup_name'],
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BulkPolicyExportJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
backupName: (string) $data['backup_name'],
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'backup_name' => (string) $data['backup_name'],
|
||||||
|
'policy_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk export started')
|
||||||
|
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->iconColor('warning')
|
||||||
|
->info()
|
||||||
|
->duration(8000)
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
|
->deselectRecordsAfterCompletion(),
|
||||||
} else {
|
)
|
||||||
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
}
|
->apply(),
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -3,8 +3,15 @@
|
|||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\PolicySyncService;
|
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\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
@ -16,59 +23,70 @@ class ListPolicies extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('sync')
|
UiEnforcement::forAction(
|
||||||
->label('Sync from Intune')
|
Actions\Action::make('sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->label('Sync from Intune')
|
||||||
->color('primary')
|
->icon('heroicon-o-arrow-path')
|
||||||
->requiresConfirmation()
|
->color('primary')
|
||||||
->action(function () {
|
->action(function (self $livewire): void {
|
||||||
try {
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
/** @var PolicySyncService $service */
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
$service = app(PolicySyncService::class);
|
abort(404);
|
||||||
|
|
||||||
$result = $service->syncPoliciesWithReport($tenant);
|
|
||||||
$syncedCount = count($result['synced'] ?? []);
|
|
||||||
$failureCount = count($result['failures'] ?? []);
|
|
||||||
|
|
||||||
$body = $syncedCount.' policies synced';
|
|
||||||
|
|
||||||
if ($failureCount > 0) {
|
|
||||||
$first = $result['failures'][0] ?? [];
|
|
||||||
$firstType = $first['policy_type'] ?? 'unknown';
|
|
||||||
$firstStatus = $first['status'] ?? null;
|
|
||||||
|
|
||||||
$firstErrorMessage = null;
|
|
||||||
$firstErrors = $first['errors'] ?? null;
|
|
||||||
if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) {
|
|
||||||
$firstErrorMessage = $firstErrors[0]['message'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}";
|
|
||||||
|
|
||||||
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
|
||||||
$suffix .= ' - '.trim($firstErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
$body .= " ({$failureCount} failed; {$suffix})";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
$requestedTypes = array_map(
|
||||||
->title('Policy sync completed')
|
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||||
->body($body)
|
config('tenantpilot.supported_policy_types', [])
|
||||||
->success()
|
);
|
||||||
->sendToDatabase(auth()->user())
|
|
||||||
|
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();
|
->send();
|
||||||
} catch (\Throwable $e) {
|
})
|
||||||
Notification::make()
|
)
|
||||||
->title('Policy sync failed')
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->body($e->getMessage())
|
->tooltip('You do not have permission to sync policies.')
|
||||||
->danger()
|
->destructive()
|
||||||
->sendToDatabase(auth()->user())
|
->apply(),
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,17 @@
|
|||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Services\Intune\VersionService;
|
use App\Jobs\CapturePolicySnapshotJob;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
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\Support\Str;
|
||||||
|
|
||||||
class ViewPolicy extends ViewRecord
|
class ViewPolicy extends ViewRecord
|
||||||
{
|
{
|
||||||
@ -23,7 +28,7 @@ protected function getActions(): array
|
|||||||
->label('Capture snapshot')
|
->label('Capture snapshot')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Capture snapshot now')
|
->modalHeading('Capture snapshot now')
|
||||||
->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.')
|
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Checkbox::make('include_assignments')
|
Forms\Components\Checkbox::make('include_assignments')
|
||||||
->label('Include assignments')
|
->label('Include assignments')
|
||||||
@ -37,51 +42,78 @@ protected function getActions(): array
|
|||||||
->action(function (array $data) {
|
->action(function (array $data) {
|
||||||
$policy = $this->record;
|
$policy = $this->record;
|
||||||
|
|
||||||
try {
|
$tenant = $policy->tenant;
|
||||||
$tenant = $policy->tenant;
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant || ! $user) {
|
||||||
Notification::make()
|
|
||||||
->title('Policy has no tenant associated.')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$version = app(VersionService::class)->captureFromGraph(
|
|
||||||
tenant: $tenant,
|
|
||||||
policy: $policy,
|
|
||||||
createdBy: auth()->user()?->email ?? null,
|
|
||||||
includeAssignments: $data['include_assignments'] ?? false,
|
|
||||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (($version->metadata['source'] ?? null) === 'metadata_only') {
|
|
||||||
$status = $version->metadata['original_status'] ?? null;
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Snapshot captured (metadata only)')
|
|
||||||
->body(sprintf(
|
|
||||||
'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.',
|
|
||||||
$status ?? 'an error'
|
|
||||||
))
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
} else {
|
|
||||||
Notification::make()
|
|
||||||
->title('Snapshot captured successfully.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()]));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Failed to capture snapshot: '.$e->getMessage())
|
->title('Missing tenant or user context.')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds([(string) $policy->getKey()]);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.capture_snapshot',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $data): void {
|
||||||
|
CapturePolicySnapshotJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyId: (int) $policy->getKey(),
|
||||||
|
includeAssignments: (bool) ($data['include_assignments'] ?? false),
|
||||||
|
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
|
||||||
|
createdBy: $user->email ? Str::limit($user->email, 255, '') : null,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
context: [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'include_assignments' => (bool) ($data['include_assignments'] ?? false),
|
||||||
|
'include_scope_tags' => (bool) ($data['include_scope_tags'] ?? false),
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Snapshot already in progress')
|
||||||
|
->body('An active run already exists for this policy. Opening run details.')
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->info()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
||||||
})
|
})
|
||||||
->color('primary'),
|
->color('primary'),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -5,7 +5,14 @@
|
|||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -19,68 +26,132 @@ class VersionsRelationManager extends RelationManager
|
|||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||||
|
->label('Restore to Intune')
|
||||||
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||||
|
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
|
->label('Preview only (dry-run)')
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Missing tenant or user context.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version belongs to a different tenant')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$run = $restoreService->executeFromPolicyVersion(
|
||||||
|
tenant: $tenant,
|
||||||
|
version: $record,
|
||||||
|
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run failed to start')
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run started')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($restoreToIntune)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$restoreToIntune
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
|
})
|
||||||
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||||
Tables\Columns\TextColumn::make('policy_type')->badge()->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->defaultSort('version_number', 'desc')
|
->defaultSort('version_number', 'desc')
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('restore_to_intune')
|
$restoreToIntune,
|
||||||
->label('Restore to Intune')
|
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
|
||||||
->color('danger')
|
|
||||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
|
||||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
|
||||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
|
||||||
->form([
|
|
||||||
Forms\Components\Toggle::make('is_dry_run')
|
|
||||||
->label('Preview only (dry-run)')
|
|
||||||
->default(true),
|
|
||||||
])
|
|
||||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if ($record->tenant_id !== $tenant->id) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy version belongs to a different tenant')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$run = $restoreService->executeFromPolicyVersion(
|
|
||||||
tenant: $tenant,
|
|
||||||
version: $record,
|
|
||||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
|
||||||
actorEmail: auth()->user()?->email,
|
|
||||||
actorName: auth()->user()?->name,
|
|
||||||
);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore run failed to start')
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore run started')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
|
||||||
}),
|
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
|
|||||||
@ -10,10 +10,19 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\Intune\VersionDiff;
|
use App\Services\Intune\VersionDiff;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -48,8 +57,14 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
|
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
|
||||||
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')
|
||||||
Infolists\Components\TextEntry::make('platform'),
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
|
Infolists\Components\TextEntry::make('platform')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(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(),
|
||||||
Tabs::make()
|
Tabs::make()
|
||||||
@ -169,12 +184,306 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
||||||
|
->label('Prune Versions')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
|
return $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->form(function (Collection $records) {
|
||||||
|
$fields = [
|
||||||
|
Forms\Components\TextInput::make('retention_days')
|
||||||
|
->label('Retention Days')
|
||||||
|
->helperText('Versions captured within the last N days will be skipped.')
|
||||||
|
->numeric()
|
||||||
|
->required()
|
||||||
|
->default(90)
|
||||||
|
->minValue(1),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($records->count() >= 20) {
|
||||||
|
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||||
|
->label('Type DELETE to confirm')
|
||||||
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
})
|
||||||
|
->action(function (Collection $records, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $user;
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy_version.prune',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
|
||||||
|
BulkPolicyVersionPruneJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
policyVersionIds: $ids,
|
||||||
|
retentionDays: $retentionDays,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'policy_version_count' => $count,
|
||||||
|
'retention_days' => $retentionDays,
|
||||||
|
],
|
||||||
|
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')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion();
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkPruneVersions)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
||||||
|
->label('Restore Versions')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
|
return ! $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||||
|
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||||
|
->action(function (Collection $records) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $user;
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy_version.restore',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
|
BulkPolicyVersionRestoreJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
policyVersionIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'policy_version_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion();
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
||||||
|
->label('Force Delete Versions')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
|
return ! $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||||
|
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||||
|
->form([
|
||||||
|
Forms\Components\TextInput::make('confirmation')
|
||||||
|
->label('Type DELETE to confirm')
|
||||||
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $user;
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy_version.force_delete',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
|
BulkPolicyVersionForceDeleteJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
policyVersionIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'policy_version_count' => $count,
|
||||||
|
],
|
||||||
|
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')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion();
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
||||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('policy_type')->badge(),
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
Tables\Columns\TextColumn::make('platform')->badge(),
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
|
Tables\Columns\TextColumn::make('platform')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||||
])
|
])
|
||||||
@ -186,347 +495,327 @@ public static function table(Table $table): Table
|
|||||||
->falseLabel('Archived'),
|
->falseLabel('Archived'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make(),
|
||||||
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
|
|
||||||
->openUrlInNewTab(false),
|
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\Action::make('restore_via_wizard')
|
(function (): Actions\Action {
|
||||||
->label('Restore via Wizard')
|
$action = Actions\Action::make('restore_via_wizard')
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
->label('Restore via Wizard')
|
||||||
->color('primary')
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
->color('primary')
|
||||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
->requiresConfirmation()
|
||||||
->requiresConfirmation()
|
->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 {
|
||||||
->action(function (PolicyVersion $record) {
|
$tenant = Tenant::current();
|
||||||
$tenant = Tenant::current();
|
$user = auth()->user();
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
Notification::make()
|
return false;
|
||||||
->title('Policy version belongs to a different tenant')
|
}
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
/** @var CapabilityResolver $resolver */
|
||||||
}
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
$policy = $record->policy;
|
return $resolver->isMember($user, $tenant);
|
||||||
|
})
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $policy) {
|
$tenant = Tenant::current();
|
||||||
Notification::make()
|
$user = auth()->user();
|
||||||
->title('Policy could not be found for this version')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$backupSet = BackupSet::create([
|
/** @var CapabilityResolver $resolver */
|
||||||
'tenant_id' => $tenant->id,
|
$resolver = app(CapabilityResolver::class);
|
||||||
'name' => sprintf(
|
|
||||||
'Policy Version Restore • %s • v%d',
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
$policy->display_name,
|
return true;
|
||||||
$record->version_number
|
}
|
||||||
),
|
|
||||||
'created_by' => $user?->email,
|
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
'status' => 'completed',
|
})
|
||||||
'item_count' => 1,
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
'completed_at' => CarbonImmutable::now(),
|
$tenant = Tenant::current();
|
||||||
'metadata' => [
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return 'You do not have permission to create restore runs.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
->action(function (PolicyVersion $record) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore disabled for metadata-only snapshot')
|
||||||
|
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version belongs to a different tenant')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = $record->policy;
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy could not be found for this version')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => sprintf(
|
||||||
|
'Policy Version Restore • %s • v%d',
|
||||||
|
$policy->display_name,
|
||||||
|
$record->version_number
|
||||||
|
),
|
||||||
|
'created_by' => $user?->email,
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
'completed_at' => CarbonImmutable::now(),
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'policy_version',
|
||||||
|
'policy_version_id' => $record->id,
|
||||||
|
'policy_version_number' => $record->version_number,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
|
||||||
|
$scopeTagIds = $scopeTags['ids'] ?? null;
|
||||||
|
$scopeTagNames = $scopeTags['names'] ?? null;
|
||||||
|
|
||||||
|
$backupItemMetadata = [
|
||||||
'source' => 'policy_version',
|
'source' => 'policy_version',
|
||||||
|
'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(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
|
||||||
|
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
|
||||||
|
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->id,
|
||||||
],
|
'policy_version_id' => $record->id,
|
||||||
]);
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
|
||||||
|
'payload' => $record->snapshot ?? [],
|
||||||
|
'metadata' => $backupItemMetadata,
|
||||||
|
'assignments' => $record->assignments,
|
||||||
|
]);
|
||||||
|
|
||||||
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
|
return redirect()->to(RestoreRunResource::getUrl('create', [
|
||||||
$scopeTagIds = $scopeTags['ids'] ?? null;
|
'backup_set_id' => $backupSet->id,
|
||||||
$scopeTagNames = $scopeTags['names'] ?? null;
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
$backupItemMetadata = [
|
return $action;
|
||||||
'source' => 'policy_version',
|
})(),
|
||||||
'display_name' => $policy->display_name,
|
(function (): Actions\Action {
|
||||||
'policy_version_id' => $record->id,
|
$action = Actions\Action::make('archive')
|
||||||
'policy_version_number' => $record->version_number,
|
->label('Archive')
|
||||||
'version_captured_at' => $record->captured_at?->toIso8601String(),
|
->color('danger')
|
||||||
];
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (PolicyVersion $record) => ! $record->trashed())
|
||||||
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
|
$record->delete();
|
||||||
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
|
|
||||||
}
|
|
||||||
|
|
||||||
$backupItem = BackupItem::create([
|
if ($record->tenant) {
|
||||||
'tenant_id' => $tenant->id,
|
$auditLogger->log(
|
||||||
'backup_set_id' => $backupSet->id,
|
tenant: $record->tenant,
|
||||||
'policy_id' => $policy->id,
|
action: 'policy_version.deleted',
|
||||||
'policy_version_id' => $record->id,
|
resourceType: 'policy_version',
|
||||||
'policy_identifier' => $policy->external_id,
|
resourceId: (string) $record->id,
|
||||||
'policy_type' => $policy->policy_type,
|
status: 'success',
|
||||||
'platform' => $policy->platform,
|
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
|
||||||
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
|
);
|
||||||
'payload' => $record->snapshot ?? [],
|
}
|
||||||
'metadata' => $backupItemMetadata,
|
|
||||||
'assignments' => $record->assignments,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->to(RestoreRunResource::getUrl('create', [
|
Notification::make()
|
||||||
'backup_set_id' => $backupSet->id,
|
->title('Policy version archived')
|
||||||
'scope_mode' => 'selected',
|
->success()
|
||||||
'backup_item_ids' => [$backupItem->id],
|
->send();
|
||||||
]));
|
});
|
||||||
}),
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label('Archive')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (PolicyVersion $record) => ! $record->trashed())
|
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
UiEnforcement::forAction($action)
|
||||||
$auditLogger->log(
|
->preserveVisibility()
|
||||||
tenant: $record->tenant,
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
action: 'policy_version.deleted',
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
resourceType: 'policy_version',
|
->apply();
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
return $action;
|
||||||
->title('Policy version archived')
|
})(),
|
||||||
->success()
|
(function (): Actions\Action {
|
||||||
->send();
|
$action = Actions\Action::make('forceDelete')
|
||||||
}),
|
->label('Force delete')
|
||||||
Actions\Action::make('forceDelete')
|
->color('danger')
|
||||||
->label('Force delete')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->requiresConfirmation()
|
||||||
->icon('heroicon-o-trash')
|
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||||
->requiresConfirmation()
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
$user = auth()->user();
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
$tenant = $record->tenant;
|
||||||
if ($record->tenant) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record->tenant,
|
|
||||||
action: 'policy_version.force_deleted',
|
|
||||||
resourceType: 'policy_version',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->forceDelete();
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
if ($record->tenant) {
|
||||||
->title('Policy version permanently deleted')
|
$auditLogger->log(
|
||||||
->success()
|
tenant: $record->tenant,
|
||||||
->send();
|
action: 'policy_version.force_deleted',
|
||||||
}),
|
resourceType: 'policy_version',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Actions\Action::make('restore')
|
$record->forceDelete();
|
||||||
->label('Restore')
|
|
||||||
->color('success')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
|
||||||
$record->restore();
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
Notification::make()
|
||||||
$auditLogger->log(
|
->title('Policy version permanently deleted')
|
||||||
tenant: $record->tenant,
|
->success()
|
||||||
action: 'policy_version.restored',
|
->send();
|
||||||
resourceType: 'policy_version',
|
});
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
UiEnforcement::forAction($action)
|
||||||
->title('Policy version restored')
|
->preserveVisibility()
|
||||||
->success()
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->send();
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
}),
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
(function (): Actions\Action {
|
||||||
|
$action = Actions\Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||||
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
|
if ($record->tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'policy_version.restored',
|
||||||
|
resourceType: 'policy_version',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
})(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
BulkAction::make('bulk_prune_versions')
|
$bulkPruneVersions,
|
||||||
->label('Prune Versions')
|
$bulkRestoreVersions,
|
||||||
->icon('heroicon-o-trash')
|
$bulkForceDeleteVersions,
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
||||||
|
|
||||||
return $isOnlyTrashed;
|
|
||||||
})
|
|
||||||
->form(function (Collection $records) {
|
|
||||||
$fields = [
|
|
||||||
Forms\Components\TextInput::make('retention_days')
|
|
||||||
->label('Retention Days')
|
|
||||||
->helperText('Versions captured within the last N days will be skipped.')
|
|
||||||
->numeric()
|
|
||||||
->required()
|
|
||||||
->default(90)
|
|
||||||
->minValue(1),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($records->count() >= 20) {
|
|
||||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
|
||||||
->label('Type DELETE to confirm')
|
|
||||||
->required()
|
|
||||||
->in(['DELETE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type DELETE to confirm.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fields;
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, array $data) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
|
||||||
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
|
|
||||||
|
|
||||||
if ($count >= 20) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk prune started')
|
|
||||||
->body("Pruning {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
|
|
||||||
} else {
|
|
||||||
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
|
|
||||||
BulkAction::make('bulk_restore_versions')
|
|
||||||
->label('Restore Versions')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
|
||||||
})
|
|
||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
|
||||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
|
||||||
->action(function (Collection $records) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
|
||||||
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count);
|
|
||||||
|
|
||||||
if ($count >= 20) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk restore started')
|
|
||||||
->body("Restoring {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
BulkPolicyVersionRestoreJob::dispatch($run->id);
|
|
||||||
} else {
|
|
||||||
BulkPolicyVersionRestoreJob::dispatchSync($run->id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
|
|
||||||
BulkAction::make('bulk_force_delete_versions')
|
|
||||||
->label('Force Delete Versions')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
|
||||||
})
|
|
||||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
|
||||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
|
||||||
->form([
|
|
||||||
Forms\Components\TextInput::make('confirmation')
|
|
||||||
->label('Type DELETE to confirm')
|
|
||||||
->required()
|
|
||||||
->in(['DELETE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type DELETE to confirm.',
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->action(function (Collection $records, array $data) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
|
||||||
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
|
|
||||||
|
|
||||||
if ($count >= 20) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk force delete started')
|
|
||||||
->body("Force deleting {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
|
|
||||||
} else {
|
|
||||||
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
667
app/Filament/Resources/ProviderConnectionResource.php
Normal file
667
app/Filament/Resources/ProviderConnectionResource.php
Normal file
@ -0,0 +1,667 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
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\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class ProviderConnectionResource extends Resource
|
||||||
|
{
|
||||||
|
use ScopesGlobalSearchToTenant;
|
||||||
|
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = ProviderConnection::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Providers';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Connections';
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'display_name';
|
||||||
|
|
||||||
|
protected static function hasTenantCapability(string $capability): 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, $capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
TextInput::make('display_name')
|
||||||
|
->label('Display name')
|
||||||
|
->required()
|
||||||
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('entra_tenant_id')
|
||||||
|
->label('Entra tenant ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
|
->rules(['uuid']),
|
||||||
|
Toggle::make('is_default')
|
||||||
|
->label('Default connection')
|
||||||
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
|
->helperText('Exactly one default connection is required per tenant/provider.'),
|
||||||
|
TextInput::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false),
|
||||||
|
TextInput::make('health_status')
|
||||||
|
->label('Health')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||||
|
})
|
||||||
|
->defaultSort('display_name')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
|
||||||
|
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
||||||
|
Tables\Columns\TextColumn::make('health_status')
|
||||||
|
->label('Health')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||||
|
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->options([
|
||||||
|
'connected' => 'Connected',
|
||||||
|
'needs_consent' => 'Needs consent',
|
||||||
|
'error' => 'Error',
|
||||||
|
'disabled' => 'Disabled',
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('status', $value);
|
||||||
|
}),
|
||||||
|
SelectFilter::make('health_status')
|
||||||
|
->label('Health')
|
||||||
|
->options([
|
||||||
|
'ok' => 'OK',
|
||||||
|
'degraded' => 'Degraded',
|
||||||
|
'down' => 'Down',
|
||||||
|
'unknown' => 'Unknown',
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('health_status', $value);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\EditAction::make()
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('check_connection')
|
||||||
|
->label('Check connection')
|
||||||
|
->icon('heroicon-o-check-badge')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $verification->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $record,
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Scope busy')
|
||||||
|
->body('Another provider operation is already running for this connection.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
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 connection check is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection check queued')
|
||||||
|
->body('Health check was queued and will run in the background.')
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('inventory_sync')
|
||||||
|
->label('Inventory sync')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('info')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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([
|
||||||
|
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([
|
||||||
|
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([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('compliance_snapshot')
|
||||||
|
->label('Compliance snapshot')
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('info')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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([
|
||||||
|
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([
|
||||||
|
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([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('set_default')
|
||||||
|
->label('Set as default')
|
||||||
|
->icon('heroicon-o-star')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||||
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('update_credentials')
|
||||||
|
->label('Update credentials')
|
||||||
|
->icon('heroicon-o-key')
|
||||||
|
->color('primary')
|
||||||
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
|
->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 = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
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)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('enable_connection')
|
||||||
|
->label('Enable connection')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hadCredentials = $record->credential()->exists();
|
||||||
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
|
$record->update([
|
||||||
|
'status' => $status,
|
||||||
|
'health_status' => 'unknown',
|
||||||
|
'last_health_check_at' => null,
|
||||||
|
'last_error_reason_code' => null,
|
||||||
|
'last_error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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 (credentials missing)')
|
||||||
|
->body('Add credentials before running checks or operations.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Provider connection enabled')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\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 = Tenant::current();
|
||||||
|
|
||||||
|
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();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->label('Actions')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListProviderConnections::route('/'),
|
||||||
|
'create' => Pages\CreateProviderConnection::route('/create'),
|
||||||
|
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateProviderConnection extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ProviderConnectionResource::class;
|
||||||
|
|
||||||
|
protected bool $shouldMakeDefault = false;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||||
|
'display_name' => $data['display_name'],
|
||||||
|
'is_default' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
$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;
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.created',
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
|
||||||
|
$hasDefault = $tenant->providerConnections()
|
||||||
|
->where('provider', $record->provider)
|
||||||
|
->where('is_default', true)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($this->shouldMakeDefault || ! $hasDefault) {
|
||||||
|
$record->makeDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Provider connection created')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,722 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
protected bool $shouldMakeDefault = false;
|
||||||
|
|
||||||
|
protected bool $defaultWasChanged = false;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
|
unset($data['is_default']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
$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 = Tenant::current();
|
||||||
|
|
||||||
|
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 = Tenant::current();
|
||||||
|
|
||||||
|
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 = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$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)),
|
||||||
|
])
|
||||||
|
->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)),
|
||||||
|
])
|
||||||
|
->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')
|
||||||
|
->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 = Tenant::current();
|
||||||
|
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
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')
|
||||||
|
->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 = Tenant::current();
|
||||||
|
|
||||||
|
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 = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hadCredentials = $record->credential()->exists();
|
||||||
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
|
$record->update([
|
||||||
|
'status' => $status,
|
||||||
|
'health_status' => 'unknown',
|
||||||
|
'last_health_check_at' => null,
|
||||||
|
'last_error_reason_code' => null,
|
||||||
|
'last_error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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 (credentials missing)')
|
||||||
|
->body('Add credentials 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 = Tenant::current();
|
||||||
|
|
||||||
|
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 = Tenant::current();
|
||||||
|
|
||||||
|
$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 = Tenant::current();
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?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\ListRecords;
|
||||||
|
|
||||||
|
class ListProviderConnections extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ProviderConnectionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\CreateAction::make()
|
||||||
|
->authorize(fn (): bool => true)
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->tooltip('You do not have permission to create provider connections.')
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -5,10 +5,14 @@
|
|||||||
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;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
class CreateRestoreRun extends CreateRecord
|
class CreateRestoreRun extends CreateRecord
|
||||||
{
|
{
|
||||||
@ -16,6 +20,27 @@ class CreateRestoreRun extends CreateRecord
|
|||||||
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
|
protected function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$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::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function getSteps(): array
|
public function getSteps(): array
|
||||||
{
|
{
|
||||||
return RestoreRunResource::getWizardSteps();
|
return RestoreRunResource::getWizardSteps();
|
||||||
@ -119,4 +144,23 @@ protected function handleRecordCreation(array $data): Model
|
|||||||
{
|
{
|
||||||
return RestoreRunResource::createRestoreRun($data);
|
return RestoreRunResource::createRestoreRun($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[On('entra-group-cache-picked')]
|
||||||
|
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
|
||||||
|
{
|
||||||
|
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
|
||||||
|
|
||||||
|
$this->data['check_summary'] = null;
|
||||||
|
$this->data['check_results'] = [];
|
||||||
|
$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);
|
||||||
|
|
||||||
|
if (method_exists($this, 'unmountAction')) {
|
||||||
|
$this->unmountAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,7 +15,7 @@ class ListRestoreRuns extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,9 +3,39 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
class CreateTenant extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,11 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
class EditTenant extends EditRecord
|
||||||
@ -14,7 +18,21 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\DeleteAction::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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,9 @@ class ListTenants extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->disabled(fn (): bool => ! TenantResource::canCreate())
|
||||||
|
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,14 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RbacHealthService;
|
use App\Services\Intune\RbacHealthService;
|
||||||
use App\Services\Intune\TenantConfigService;
|
use App\Services\Intune\TenantConfigService;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@ -16,11 +19,25 @@ class ViewTenant extends ViewRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TenantArchivedBanner::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\EditAction::make(),
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('edit')
|
||||||
|
->label('Edit')
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Admin consent')
|
->label('Admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
@ -48,30 +65,40 @@ protected function getHeaderActions(): array
|
|||||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||||
}),
|
}),
|
||||||
TenantResource::rbacAction(),
|
TenantResource::rbacAction(),
|
||||||
Actions\Action::make('archive')
|
UiEnforcement::forAction(
|
||||||
->label('Deactivate')
|
Actions\Action::make('archive')
|
||||||
->color('danger')
|
->label('Deactivate')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->visible(fn (Tenant $record) => ! $record->trashed())
|
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record,
|
tenant: $record,
|
||||||
action: 'tenant.archived',
|
action: 'tenant.archived',
|
||||||
resourceType: 'tenant',
|
resourceType: 'tenant',
|
||||||
resourceId: (string) $record->id,
|
resourceId: (string) $record->getKey(),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
context: [
|
||||||
);
|
'metadata' => [
|
||||||
|
'internal_tenant_id' => (int) $record->getKey(),
|
||||||
|
'tenant_guid' => (string) $record->tenant_id,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Tenant deactivated')
|
->title('Tenant deactivated')
|
||||||
->body('The tenant has been archived and hidden from lists.')
|
->body('The tenant has been archived and hidden from lists.')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class TenantMembershipsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
|
->label(__('User'))
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
|
->label(__('Domain'))
|
||||||
|
->getStateUsing(function (TenantMembership $record): ?string {
|
||||||
|
$email = $record->user?->email;
|
||||||
|
|
||||||
|
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) str($email)->after('@')->lower();
|
||||||
|
}),
|
||||||
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('role')
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('source')
|
||||||
|
->badge()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('add_member')
|
||||||
|
->label(__('Add member'))
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Select::make('user_id')
|
||||||
|
->label(__('User'))
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->options(fn () => User::query()
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||||
|
])
|
||||||
|
->all()),
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
'owner' => __('Owner'),
|
||||||
|
'manager' => __('Manager'),
|
||||||
|
'operator' => __('Operator'),
|
||||||
|
'readonly' => __('Readonly'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (array $data, TenantMembershipManager $manager): void {
|
||||||
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = User::query()->find((int) $data['user_id']);
|
||||||
|
if (! $member) {
|
||||||
|
Notification::make()->title(__('User not found'))->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manager->addMember(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
member: $member,
|
||||||
|
role: (string) $data['role'],
|
||||||
|
source: 'manual',
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to add member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Member added'))->success()->send();
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('change_role')
|
||||||
|
->label(__('Change role'))
|
||||||
|
->icon('heroicon-o-pencil')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
'owner' => __('Owner'),
|
||||||
|
'manager' => __('Manager'),
|
||||||
|
'operator' => __('Operator'),
|
||||||
|
'readonly' => __('Readonly'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
||||||
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manager->changeRole(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
membership: $record,
|
||||||
|
newRole: (string) $data['role'],
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to change role'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Role updated'))->success()->send();
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('remove')
|
||||||
|
->label(__('Remove'))
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
||||||
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manager->removeMember($tenant, $actor, $record);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to remove member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Member removed'))->success()->send();
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal file
35
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateWorkspace extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkspaceMembership::query()->firstOrCreate(
|
||||||
|
[
|
||||||
|
'workspace_id' => $this->record->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'role' => 'owner',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal file
11
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditWorkspace extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal file
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWorkspaces extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal file
19
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewWorkspace extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\WorkspaceMembershipManager;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class WorkspaceMembershipsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
|
->label(__('User'))
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
|
->label(__('Domain'))
|
||||||
|
->getStateUsing(function (WorkspaceMembership $record): ?string {
|
||||||
|
$email = $record->user?->email;
|
||||||
|
|
||||||
|
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) str($email)->after('@')->lower();
|
||||||
|
}),
|
||||||
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('role')
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
|
Action::make('add_member')
|
||||||
|
->label(__('Add member'))
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Select::make('user_id')
|
||||||
|
->label(__('User'))
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->options(fn () => User::query()
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||||
|
])
|
||||||
|
->all()),
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
WorkspaceRole::Owner->value => __('Owner'),
|
||||||
|
WorkspaceRole::Manager->value => __('Manager'),
|
||||||
|
WorkspaceRole::Operator->value => __('Operator'),
|
||||||
|
WorkspaceRole::Readonly->value => __('Readonly'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (array $data, WorkspaceMembershipManager $manager): void {
|
||||||
|
$workspace = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = User::query()->find((int) $data['user_id']);
|
||||||
|
if (! $member) {
|
||||||
|
Notification::make()->title(__('User not found'))->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manager->addMember(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $actor,
|
||||||
|
member: $member,
|
||||||
|
role: (string) $data['role'],
|
||||||
|
source: 'manual',
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to add member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Member added'))->success()->send();
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
|
Action::make('change_role')
|
||||||
|
->label(__('Change role'))
|
||||||
|
->icon('heroicon-o-pencil')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
WorkspaceRole::Owner->value => __('Owner'),
|
||||||
|
WorkspaceRole::Manager->value => __('Manager'),
|
||||||
|
WorkspaceRole::Operator->value => __('Operator'),
|
||||||
|
WorkspaceRole::Readonly->value => __('Readonly'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void {
|
||||||
|
$workspace = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manager->changeRole(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $actor,
|
||||||
|
membership: $record,
|
||||||
|
newRole: (string) $data['role'],
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to change role'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Role updated'))->success()->send();
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
|
Action::make('remove')
|
||||||
|
->label(__('Remove'))
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
|
||||||
|
$workspace = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to remove member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Member removed'))->success()->send();
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal file
79
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class WorkspaceResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Workspace::class;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('slug')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('slug')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListWorkspaces::route('/'),
|
||||||
|
'create' => Pages\CreateWorkspace::route('/create'),
|
||||||
|
'view' => Pages\ViewWorkspace::route('/{record}'),
|
||||||
|
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
WorkspaceMembershipsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Filament/Support/VerificationReportViewer.php
Normal file
44
app/Filament/Support/VerificationReportViewer.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Verification\VerificationReportSanitizer;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
|
|
||||||
|
final class VerificationReportViewer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function report(OperationRun $run): ?array
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$report = $context['verification_report'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||||
|
|
||||||
|
if (! VerificationReportSchema::isValidReport($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shouldRenderForRun(OperationRun $run): bool
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
|
if (array_key_exists('verification_report', $context)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/Filament/System/Pages/Auth/Login.php
Normal file
82
app/Filament/System/Pages/Auth/Login.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages\Auth;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||||
|
use Filament\Auth\Pages\Login as BaseLogin;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class Login extends BaseLogin
|
||||||
|
{
|
||||||
|
public function authenticate(): ?LoginResponse
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
$email = (string) ($data['email'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = parent::authenticate();
|
||||||
|
} catch (ValidationException $exception) {
|
||||||
|
$this->audit(status: 'failure', email: $email, actor: null, reason: 'invalid_credentials');
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var PlatformUser|null $user */
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! ($user instanceof PlatformUser)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
auth('platform')->logout();
|
||||||
|
|
||||||
|
$this->audit(status: 'failure', email: $email, actor: null, reason: 'inactive');
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'data.email' => __('filament-panels::auth/pages/login.messages.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill(['last_login_at' => now()])->saveQuietly();
|
||||||
|
|
||||||
|
$this->audit(status: 'success', email: $email, actor: $user);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function audit(string $status, string $email, ?PlatformUser $actor, ?string $reason = null): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'platform.auth.login',
|
||||||
|
context: [
|
||||||
|
'attempted_email' => $email,
|
||||||
|
'ip' => request()->ip(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
'reason' => $reason,
|
||||||
|
],
|
||||||
|
actorId: $actor?->getKey(),
|
||||||
|
actorEmail: $actor?->email ?? ($email ?: null),
|
||||||
|
actorName: $actor?->name,
|
||||||
|
status: $status,
|
||||||
|
resourceType: 'platform_user',
|
||||||
|
resourceId: $actor ? (string) $actor->getKey() : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Filament/System/Pages/Dashboard.php
Normal file
87
app/Filament/System/Pages/Dashboard.php
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Dashboard as BaseDashboard;
|
||||||
|
|
||||||
|
class Dashboard extends BaseDashboard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$breakGlass = app(BreakGlassSession::class);
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
$canUseBreakGlass = $breakGlass->isEnabled()
|
||||||
|
&& $user instanceof PlatformUser
|
||||||
|
&& $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('enter_break_glass')
|
||||||
|
->label('Enter break-glass mode')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Enter break-glass mode')
|
||||||
|
->modalDescription('Recovery mode is time-limited and fully audited. Use for recovery only.')
|
||||||
|
->form([
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4),
|
||||||
|
])
|
||||||
|
->action(function (array $data, BreakGlassSession $breakGlass): void {
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Recovery mode enabled')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('exit_break_glass')
|
||||||
|
->label('Exit break-glass')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Exit break-glass mode')
|
||||||
|
->modalDescription('This will immediately end recovery mode.')
|
||||||
|
->action(function (BreakGlassSession $breakGlass): void {
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$breakGlass->exit($user);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Recovery mode ended')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user