Compare commits
21 Commits
feat/057-f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 439248ba15 | |||
| b6343d5c3a | |||
| 5f9e6fb04a | |||
| 38d9826f5e | |||
| a989ef1a23 | |||
| 3490fb9e2c | |||
| d1a9989037 | |||
| 7217559e5a | |||
| 6a86c5901a | |||
| cfbc74c035 | |||
| d90fb0f963 | |||
| 3a3de045ba | |||
| 210cf5ce8b | |||
| c5fbcaa692 | |||
| 81c010fa00 | |||
| eef85af990 | |||
| a0ed9e24c5 | |||
| 1bc6600fcc | |||
| 0b6600b926 | |||
| e1ed7ae232 | |||
| ec9f28ccbd |
@ -1,14 +1,21 @@
|
||||
node_modules/
|
||||
vendor/
|
||||
.git/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.tmp
|
||||
*.swp
|
||||
public/build/
|
||||
public/hot/
|
||||
public/storage/
|
||||
storage/framework/
|
||||
storage/logs/
|
||||
storage/debugbar/
|
||||
storage/*.key
|
||||
/references/
|
||||
|
||||
10
.env.example
10
.env.example
@ -63,3 +63,13 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
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
|
||||
|
||||
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -12,6 +12,10 @@ ## Active Technologies
|
||||
- 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)
|
||||
|
||||
@ -31,9 +35,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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
|
||||
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
140
.github/copilot-instructions.md
vendored
140
.github/copilot-instructions.md
vendored
@ -175,7 +175,6 @@ ## 15) Agent output contract
|
||||
- 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)
|
||||
@ -258,7 +257,6 @@ ## 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
|
||||
@ -272,6 +270,7 @@ ## Foundational Context
|
||||
- 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
|
||||
@ -281,7 +280,7 @@ ## Foundational Context
|
||||
- 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, 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()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
@ -289,7 +288,7 @@ ## 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.
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
@ -301,17 +300,16 @@ ## Replies
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@ -322,22 +320,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
|
||||
- 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. 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
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 ===
|
||||
|
||||
@ -348,7 +345,7 @@ ## PHP
|
||||
### 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.
|
||||
- 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.
|
||||
@ -362,7 +359,7 @@ ### Type Declarations
|
||||
</code-snippet>
|
||||
|
||||
## 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
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
@ -370,7 +367,6 @@ ## PHPDoc Blocks
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== sail rules ===
|
||||
|
||||
## Laravel Sail
|
||||
@ -378,21 +374,19 @@ ## 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]`
|
||||
- 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` 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 ===
|
||||
|
||||
@ -404,7 +398,7 @@ ## Do Things the Laravel Way
|
||||
|
||||
### 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
|
||||
- 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.
|
||||
@ -439,36 +433,36 @@ ### Testing
|
||||
### 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.
|
||||
- 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
|
||||
- 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/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - 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.
|
||||
- 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 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
|
||||
- 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
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
## 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.
|
||||
- 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.
|
||||
@ -485,15 +479,14 @@ ## Livewire Best Practices
|
||||
|
||||
- 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 updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
@ -502,12 +495,10 @@ ## Testing Livewire
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</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 ===
|
||||
|
||||
@ -516,7 +507,6 @@ ## 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
|
||||
@ -537,9 +527,9 @@ ### Pest Tests
|
||||
|
||||
### 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`.
|
||||
- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- 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
|
||||
@ -558,7 +548,7 @@ ### Mocking
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### 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">
|
||||
it('has emails', function (string $email) {
|
||||
@ -569,18 +559,17 @@ ### Datasets
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## 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 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 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.
|
||||
- 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).
|
||||
@ -614,39 +603,37 @@ ### Example Tests
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== 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.
|
||||
- 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
|
||||
- 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>
|
||||
- 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 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.
|
||||
- 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);
|
||||
@ -662,9 +649,8 @@ ## Tailwind 4
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
@ -21,7 +22,10 @@ coverage/
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework
|
||||
/storage/logs
|
||||
/vendor
|
||||
/bootstrap/cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
@ -3,7 +3,11 @@ dist/
|
||||
build/
|
||||
public/build/
|
||||
public/hot/
|
||||
public/storage/
|
||||
coverage/
|
||||
vendor/
|
||||
storage/
|
||||
bootstrap/cache/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.2.0 → 1.2.1
|
||||
- Version change: 1.5.0 → 1.6.0
|
||||
- Modified principles:
|
||||
- Operations / Run Observability Standard (clarify AuditLog vs OperationRun)
|
||||
- Added sections: None
|
||||
- Removed sections: None
|
||||
- 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/tasks-template.md
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- Follow-up TODOs:
|
||||
- TODO(DELETED_STATUS): Keep “deleted” reserved for Feature 900 / Policy Lifecycle.
|
||||
- ✅ .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
|
||||
@ -42,6 +44,72 @@ ### 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.
|
||||
- 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.
|
||||
@ -51,6 +119,10 @@ ### Operations / Run Observability Standard
|
||||
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),
|
||||
@ -72,13 +144,19 @@ ### Data Minimization & Safe Logging
|
||||
- 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
|
||||
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
||||
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
|
||||
|
||||
## Quality Gates
|
||||
- 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
|
||||
|
||||
@ -96,4 +174,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.2.1 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-17
|
||||
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
|
||||
|
||||
@ -35,10 +35,14 @@ ## Constitution Check
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
@ -82,6 +82,24 @@ ## Requirements *(mandatory)*
|
||||
(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.
|
||||
Fill them out with the right functional requirements.
|
||||
|
||||
@ -12,6 +12,20 @@ # Tasks: [FEATURE NAME]
|
||||
**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.
|
||||
|
||||
|
||||
147
Agents.md
147
Agents.md
@ -562,7 +562,6 @@ ## 15) Agent output contract
|
||||
- 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)
|
||||
@ -645,7 +644,6 @@ ## 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
|
||||
@ -659,6 +657,7 @@ ## Foundational Context
|
||||
- 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
|
||||
@ -668,7 +667,7 @@ ## Foundational Context
|
||||
- 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, 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()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
@ -676,7 +675,7 @@ ## 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.
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
@ -688,17 +687,16 @@ ## Replies
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@ -709,22 +707,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
|
||||
- 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. 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
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 ===
|
||||
|
||||
@ -735,7 +732,7 @@ ## PHP
|
||||
### 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.
|
||||
- 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.
|
||||
@ -749,7 +746,7 @@ ### Type Declarations
|
||||
</code-snippet>
|
||||
|
||||
## 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
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
@ -757,7 +754,6 @@ ## PHPDoc Blocks
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== sail rules ===
|
||||
|
||||
## Laravel Sail
|
||||
@ -765,21 +761,19 @@ ## 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]`
|
||||
- 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` 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 ===
|
||||
|
||||
@ -791,7 +785,7 @@ ## Do Things the Laravel Way
|
||||
|
||||
### 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
|
||||
- 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.
|
||||
@ -826,36 +820,36 @@ ### Testing
|
||||
### 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.
|
||||
- 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
|
||||
- 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/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - 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.
|
||||
- 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 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
|
||||
- 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
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
## 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.
|
||||
- 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.
|
||||
@ -872,15 +866,14 @@ ## Livewire Best Practices
|
||||
|
||||
- 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 updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
@ -889,12 +882,10 @@ ## Testing Livewire
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</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 ===
|
||||
|
||||
@ -903,7 +894,6 @@ ## 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
|
||||
@ -924,9 +914,9 @@ ### Pest Tests
|
||||
|
||||
### 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`.
|
||||
- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- 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
|
||||
@ -945,7 +935,7 @@ ### Mocking
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### 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">
|
||||
it('has emails', function (string $email) {
|
||||
@ -956,18 +946,17 @@ ### Datasets
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## 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 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 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.
|
||||
- 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).
|
||||
@ -1001,39 +990,37 @@ ### Example Tests
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== 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.
|
||||
- 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
|
||||
- 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>
|
||||
- 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 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.
|
||||
- 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);
|
||||
@ -1049,9 +1036,8 @@ ## Tailwind 4
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
@ -1070,8 +1056,9 @@ ### Replaced Utilities
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Active Technologies
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 (054-unify-runs-suitewide-session-1768601416)
|
||||
- PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) (054-unify-runs-suitewide-session-1768601416)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4
|
||||
- PostgreSQL (Sail)
|
||||
- Tailwind CSS v4
|
||||
|
||||
## Recent Changes
|
||||
- 054-unify-runs-suitewide-session-1768601416: Added PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3
|
||||
- 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts)
|
||||
|
||||
148
GEMINI.md
148
GEMINI.md
@ -402,7 +402,6 @@ ## 15) Agent output contract
|
||||
- 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)
|
||||
@ -485,7 +484,6 @@ ## 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
|
||||
@ -499,6 +497,7 @@ ## Foundational Context
|
||||
- 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
|
||||
@ -508,7 +507,7 @@ ## Foundational Context
|
||||
- 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, 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()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
@ -516,7 +515,7 @@ ## 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.
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
@ -528,17 +527,16 @@ ## Replies
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@ -549,22 +547,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
|
||||
- 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. 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
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 ===
|
||||
|
||||
@ -575,7 +572,7 @@ ## PHP
|
||||
### 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.
|
||||
- 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.
|
||||
@ -589,7 +586,7 @@ ### Type Declarations
|
||||
</code-snippet>
|
||||
|
||||
## 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
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
@ -597,7 +594,6 @@ ## PHPDoc Blocks
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== sail rules ===
|
||||
|
||||
## Laravel Sail
|
||||
@ -605,21 +601,19 @@ ## 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]`
|
||||
- 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` 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 ===
|
||||
|
||||
@ -631,7 +625,7 @@ ## Do Things the Laravel Way
|
||||
|
||||
### 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
|
||||
- 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.
|
||||
@ -666,36 +660,36 @@ ### Testing
|
||||
### 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.
|
||||
- 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
|
||||
- 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/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - 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.
|
||||
- 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 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
|
||||
- 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
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
## 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.
|
||||
- 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.
|
||||
@ -712,15 +706,14 @@ ## Livewire Best Practices
|
||||
|
||||
- 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 updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
@ -729,12 +722,10 @@ ## Testing Livewire
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</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 ===
|
||||
|
||||
@ -743,7 +734,6 @@ ## 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
|
||||
@ -764,9 +754,9 @@ ### Pest Tests
|
||||
|
||||
### 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`.
|
||||
- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- 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
|
||||
@ -785,7 +775,7 @@ ### Mocking
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### 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">
|
||||
it('has emails', function (string $email) {
|
||||
@ -796,18 +786,17 @@ ### Datasets
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## 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 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 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.
|
||||
- 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).
|
||||
@ -841,39 +830,37 @@ ### Example Tests
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== 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.
|
||||
- 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
|
||||
- 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>
|
||||
- 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 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.
|
||||
- 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);
|
||||
@ -889,9 +876,8 @@ ## Tailwind 4
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
@ -910,9 +896,9 @@ ### Replaced Utilities
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Recent Changes
|
||||
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3
|
||||
- 054-unify-runs-suitewide: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3
|
||||
- 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
|
||||
- PostgreSQL (`operation_runs` table + JSONB) (054-unify-runs-suitewide)
|
||||
- PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 (065-tenant-rbac-v1)
|
||||
|
||||
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
return Tenant::currentOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,7 +138,7 @@ private function resolveTenants()
|
||||
}
|
||||
|
||||
try {
|
||||
return collect([Tenant::current()]);
|
||||
return collect([Tenant::currentOrFail()]);
|
||||
} catch (RuntimeException) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -10,9 +10,11 @@
|
||||
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;
|
||||
@ -173,7 +175,10 @@ public function mount(): void
|
||||
}
|
||||
}
|
||||
|
||||
if (! $user->canSyncTenant($tenant)) {
|
||||
/** @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.';
|
||||
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
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;
|
||||
@ -11,12 +14,23 @@ 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>>
|
||||
*/
|
||||
@ -29,12 +43,9 @@ class InventoryCoverage extends Page
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$policyTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$foundationTypes = config('tenantpilot.foundation_types', []);
|
||||
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$this->supportedPolicyTypes = collect(is_array($policyTypes) ? $policyTypes : [])
|
||||
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
|
||||
->map(function (array $row) use ($resolver): array {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
|
||||
@ -44,7 +55,7 @@ public function mount(): void
|
||||
})
|
||||
->all();
|
||||
|
||||
$this->foundationTypes = collect(is_array($foundationTypes) ? $foundationTypes : [])
|
||||
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
|
||||
->map(function (array $row): array {
|
||||
return array_merge($row, [
|
||||
'dependencies' => false,
|
||||
|
||||
@ -2,255 +2,37 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
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\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use BackedEnum;
|
||||
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\Pages\Page;
|
||||
use Filament\Support\Enums\Size;
|
||||
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 = 'Inventory';
|
||||
protected static ?string $navigationLabel = 'Overview';
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected string $view = 'filament.pages.inventory-landing';
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
public function mount(): void
|
||||
{
|
||||
$this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current()));
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
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(config('tenantpilot.supported_policy_types', []))
|
||||
->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;
|
||||
}
|
||||
|
||||
return $user->canSyncTenant(Tenant::current());
|
||||
})
|
||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
if (! $user->canSyncTenant($tenant)) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$requestedTenantId = $data['tenant_id'] ?? null;
|
||||
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Not allowed')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$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);
|
||||
}),
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getInventoryItemsUrl(): string
|
||||
{
|
||||
return InventoryItemResource::getUrl('index', tenant: Tenant::current());
|
||||
}
|
||||
|
||||
public function getSyncRunsUrl(): string
|
||||
{
|
||||
return InventorySyncRunResource::getUrl('index', tenant: Tenant::current());
|
||||
}
|
||||
|
||||
public function getCoverageUrl(): string
|
||||
{
|
||||
return InventoryCoverage::getUrl(tenant: Tenant::current());
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
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;
|
||||
@ -51,21 +53,17 @@ public function table(Table $table): Table
|
||||
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->colors([
|
||||
'secondary' => 'queued',
|
||||
'warning' => 'running',
|
||||
'success' => 'completed',
|
||||
]),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->colors([
|
||||
'gray' => 'pending',
|
||||
'success' => 'succeeded',
|
||||
'warning' => 'partially_succeeded',
|
||||
'danger' => 'failed',
|
||||
'secondary' => 'cancelled',
|
||||
]),
|
||||
->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')
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,11 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
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;
|
||||
@ -19,7 +23,42 @@ public static function getLabel(): string
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
return true;
|
||||
$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
|
||||
@ -68,14 +107,46 @@ public function form(Schema $schema): Schema
|
||||
*/
|
||||
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' => TenantRole::Owner->value],
|
||||
$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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
1294
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
1294
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));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,8 @@
|
||||
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;
|
||||
@ -19,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
|
||||
->defaultSort('scheduled_for', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('scheduled_for')
|
||||
@ -27,15 +29,10 @@ public function table(Table $table): Table
|
||||
->dateTime(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'success',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'warning',
|
||||
BackupScheduleRun::STATUS_RUNNING => 'primary',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'gray',
|
||||
BackupScheduleRun::STATUS_FAILED,
|
||||
BackupScheduleRun::STATUS_CANCELED => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
->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 {
|
||||
|
||||
@ -10,17 +10,23 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
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 Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -41,6 +47,22 @@ class BackupSetResource extends Resource
|
||||
|
||||
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
|
||||
{
|
||||
return $schema
|
||||
@ -57,7 +79,12 @@ public static function table(Table $table): Table
|
||||
return $table
|
||||
->columns([
|
||||
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('created_by')->label('Created by'),
|
||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
||||
@ -75,323 +102,356 @@ public static function table(Table $table): Table
|
||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
ActionGroup::make([
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record) => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.restored',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set restored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->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.restored',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
->title('Cannot force delete backup set')
|
||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||
->danger()
|
||||
->title('Backup set restored')
|
||||
->success()
|
||||
->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) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.force_deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]]
|
||||
);
|
||||
}
|
||||
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]]
|
||||
);
|
||||
}
|
||||
|
||||
$record->items()->withTrashed()->forceDelete();
|
||||
$record->forceDelete();
|
||||
Notification::make()
|
||||
->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()
|
||||
->title('Backup set permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
->title('Cannot force delete backup set')
|
||||
->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'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Backup Sets')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Backup Sets')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->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);
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return $isOnlyTrashed;
|
||||
})
|
||||
->modalDescription('This archives backup sets (soft delete). Already archived 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;
|
||||
})
|
||||
->modalDescription('This archives backup sets (soft delete). Already archived 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 [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$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,
|
||||
);
|
||||
$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();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
BulkAction::make('bulk_restore')
|
||||
->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;
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->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);
|
||||
$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;
|
||||
})
|
||||
->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();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
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,
|
||||
);
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
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();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->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;
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->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);
|
||||
$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;
|
||||
})
|
||||
->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 [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
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,
|
||||
);
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
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();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@ -401,7 +461,12 @@ public static function infolist(Schema $schema): Schema
|
||||
return $schema
|
||||
->schema([
|
||||
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('created_by')->label('Created by'),
|
||||
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -13,7 +15,7 @@ class ListBackupSets extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,9 +8,15 @@
|
||||
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\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
@ -35,6 +41,199 @@ public function closeAddPoliciesModal(): void
|
||||
|
||||
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
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->columns([
|
||||
@ -51,21 +250,31 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->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')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->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')
|
||||
->label('Risk')
|
||||
->badge()
|
||||
->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')
|
||||
->label('Policy ID')
|
||||
->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')
|
||||
->label('Assignments')
|
||||
->badge()
|
||||
@ -109,25 +318,8 @@ public function table(Table $table): Table
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([
|
||||
Actions\Action::make('refreshTable')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
}),
|
||||
Actions\Action::make('addPolicies')
|
||||
->label('Add Policies')
|
||||
->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(),
|
||||
]);
|
||||
}),
|
||||
$refreshTable,
|
||||
$addPolicies,
|
||||
])
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
@ -144,166 +336,12 @@ public function table(Table $table): Table
|
||||
})
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
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 (! $user->canSyncTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$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();
|
||||
}),
|
||||
$removeItem,
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\BulkActionGroup::make([
|
||||
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 (! $user->canSyncTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$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();
|
||||
}),
|
||||
$bulkRemove,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
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;
|
||||
@ -46,8 +48,20 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
|
||||
TextEntry::make('security_enabled')->label('Security')->badge(),
|
||||
TextEntry::make('mail_enabled')->label('Mail')->badge(),
|
||||
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)
|
||||
|
||||
@ -10,7 +10,9 @@
|
||||
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;
|
||||
@ -27,91 +29,90 @@ protected function getHeaderActions(): array
|
||||
->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();
|
||||
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||
|
||||
$tenant = Tenant::current();
|
||||
// --- 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 (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
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();
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
return;
|
||||
}
|
||||
// ----------------------------------------------
|
||||
|
||||
return $role?->canSync() ?? false;
|
||||
})
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
$existing = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', $selectionKey)
|
||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
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();
|
||||
|
||||
$tenant = Tenant::current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
abort(403);
|
||||
}
|
||||
$run = EntraGroupSyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => null,
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
dispatch(new EntraGroupSyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
selectionKey: $selectionKey,
|
||||
slotKey: null,
|
||||
runId: (int) $run->getKey(),
|
||||
operationRun: $opRun
|
||||
));
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
if (! ($role?->canSync() ?? false)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$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()
|
||||
->title('Group sync started')
|
||||
->body('Sync dispatched.')
|
||||
->success()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
@ -119,38 +120,11 @@ protected function getHeaderActions(): array
|
||||
])
|
||||
->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
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;
|
||||
@ -60,7 +62,10 @@ public static function infolist(Schema $schema): Schema
|
||||
->placeholder('—'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)),
|
||||
->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(),
|
||||
@ -106,7 +111,10 @@ public static function table(Table $table): Table
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)),
|
||||
->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)
|
||||
@ -143,16 +151,4 @@ public static function getPages(): array
|
||||
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function statusColor(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
EntraGroupSyncRun::STATUS_SUCCEEDED => 'success',
|
||||
EntraGroupSyncRun::STATUS_PARTIAL => 'warning',
|
||||
EntraGroupSyncRun::STATUS_FAILED => 'danger',
|
||||
EntraGroupSyncRun::STATUS_RUNNING => 'info',
|
||||
EntraGroupSyncRun::STATUS_PENDING => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
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;
|
||||
|
||||
@ -19,94 +22,67 @@ class ListEntraGroupSyncRuns extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
$existing = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', $selectionKey)
|
||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
if ($existing instanceof EntraGroupSyncRun) {
|
||||
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
||||
|
||||
return $role?->canSync() ?? false;
|
||||
})
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
$user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'run_type' => 'directory_groups',
|
||||
'run_id' => (int) $existing->getKey(),
|
||||
'status' => $normalizedStatus,
|
||||
]));
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$run = EntraGroupSyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => null,
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
if (! $tenant) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
if (! ($role?->canSync() ?? false)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$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';
|
||||
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) $existing->getKey(),
|
||||
'status' => $normalizedStatus,
|
||||
'run_id' => (int) $run->getKey(),
|
||||
'status' => 'queued',
|
||||
]));
|
||||
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,16 @@
|
||||
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;
|
||||
@ -23,9 +29,9 @@
|
||||
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 Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingResource extends Resource
|
||||
@ -38,6 +44,48 @@ class FindingResource extends Resource
|
||||
|
||||
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;
|
||||
@ -50,8 +98,18 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Finding')
|
||||
->schema([
|
||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||
TextEntry::make('status')->badge(),
|
||||
TextEntry::make('severity')->badge(),
|
||||
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('—'),
|
||||
@ -188,8 +246,18 @@ public static function table(Table $table): Table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
||||
Tables\Columns\TextColumn::make('status')->badge(),
|
||||
Tables\Columns\TextColumn::make('severity')->badge(),
|
||||
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),
|
||||
@ -292,82 +360,69 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('acknowledge_selected')
|
||||
->label('Acknowledge selected')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->authorize(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
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 || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||
|
||||
return $user->can('update', $probe);
|
||||
})
|
||||
->authorizeIndividualRecords('update')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$firstRecord = $records->first();
|
||||
if ($firstRecord instanceof Finding) {
|
||||
Gate::authorize('update', $firstRecord);
|
||||
}
|
||||
|
||||
$acknowledgedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (! $record instanceof Finding) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
$skippedCount++;
|
||||
$acknowledgedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
continue;
|
||||
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++;
|
||||
}
|
||||
|
||||
if ($record->status !== Finding::STATUS_NEW) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
||||
if ($skippedCount > 0) {
|
||||
$body .= " Skipped {$skippedCount}.";
|
||||
}
|
||||
|
||||
$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(),
|
||||
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();
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->addSelect([
|
||||
|
||||
@ -4,15 +4,15 @@
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
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;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
@ -21,101 +21,83 @@ class ListFindings extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('acknowledge_all_matching')
|
||||
->label('Acknowledge all matching')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->authorize(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
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();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
||||
})
|
||||
->form(function (): array {
|
||||
$count = $this->getAllMatchingCount();
|
||||
|
||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||
if ($count <= 100) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $user->can('update', $probe);
|
||||
})
|
||||
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
||||
->modalDescription(function (): string {
|
||||
$count = $this->getAllMatchingCount();
|
||||
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();
|
||||
|
||||
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
||||
})
|
||||
->form(function (): array {
|
||||
$count = $this->getAllMatchingCount();
|
||||
if ($count === 0) {
|
||||
Notification::make()
|
||||
->title('No matching findings')
|
||||
->body('There are no new findings matching the current filters.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
if ($count <= 100) {
|
||||
return [];
|
||||
}
|
||||
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 {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$updated = $query->update([
|
||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
$this->deselectAllTableRecords();
|
||||
$this->resetPage();
|
||||
|
||||
$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()
|
||||
->title('Bulk acknowledge completed')
|
||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$firstRecord = (clone $query)->first();
|
||||
if ($firstRecord instanceof Finding) {
|
||||
Gate::authorize('update', $firstRecord);
|
||||
}
|
||||
|
||||
$updated = $query->update([
|
||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$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
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$query = Finding::query();
|
||||
|
||||
if (! $tenant) {
|
||||
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$query->where('tenant_id', $tenant->getKey());
|
||||
$query->where('tenant_id', (int) $tenantId);
|
||||
|
||||
$query->where('status', Finding::STATUS_NEW);
|
||||
|
||||
|
||||
@ -2,12 +2,21 @@
|
||||
|
||||
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;
|
||||
@ -18,16 +27,62 @@
|
||||
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;
|
||||
@ -43,12 +98,18 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['label'] ?? (string) $record->policy_type),
|
||||
->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'] ?? 'Unknown')),
|
||||
TextEntry::make('platform')->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')
|
||||
@ -64,11 +125,19 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('support_restore')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
|
||||
->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'),
|
||||
->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(),
|
||||
@ -138,17 +207,52 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => static::typeMeta($state)['label'] ?? (string) $state),
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->badge(),
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->badge(),
|
||||
->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(),
|
||||
->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')
|
||||
@ -166,7 +270,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
@ -190,8 +294,7 @@ private static function typeMeta(?string $type): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(static::allTypeMeta())
|
||||
->firstWhere('type', $type) ?? [];
|
||||
return InventoryPolicyTypeMeta::metaFor($type);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -199,12 +302,6 @@ private static function typeMeta(?string $type): array
|
||||
*/
|
||||
private static function allTypeMeta(): array
|
||||
{
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
$foundations = config('tenantpilot.foundation_types', []);
|
||||
|
||||
return array_merge(
|
||||
is_array($supported) ? $supported : [],
|
||||
is_array($foundations) ? $foundations : [],
|
||||
);
|
||||
return InventoryPolicyTypeMeta::all();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,249 @@
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
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;
|
||||
@ -16,18 +22,66 @@
|
||||
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 = false;
|
||||
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;
|
||||
@ -56,14 +110,23 @@ public static function infolist(Schema $schema): Schema
|
||||
->placeholder('—'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
|
||||
->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(),
|
||||
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(),
|
||||
@ -106,7 +169,10 @@ public static function table(Table $table): Table
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
|
||||
->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()
|
||||
@ -131,7 +197,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('user')
|
||||
@ -145,16 +211,4 @@ public static function getPages(): array
|
||||
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function statusColor(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
InventorySyncRun::STATUS_SUCCESS => 'success',
|
||||
InventorySyncRun::STATUS_PARTIAL => 'warning',
|
||||
InventorySyncRun::STATUS_FAILED => 'danger',
|
||||
InventorySyncRun::STATUS_SKIPPED => 'gray',
|
||||
InventorySyncRun::STATUS_RUNNING => 'info',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,17 @@
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,11 @@
|
||||
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;
|
||||
@ -63,10 +66,16 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextEntry::make('outcome')
|
||||
->badge()
|
||||
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
|
||||
->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')
|
||||
@ -128,12 +137,35 @@ public static function infolist(Schema $schema): Schema
|
||||
->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(fn (OperationRun $record): array => $record->context ?? [])
|
||||
->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(),
|
||||
@ -144,15 +176,13 @@ public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', '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('status')
|
||||
->badge()
|
||||
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
|
||||
->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))
|
||||
@ -175,7 +205,10 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->badge()
|
||||
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
|
||||
->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')
|
||||
@ -261,27 +294,6 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
private static function statusColor(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'queued' => 'secondary',
|
||||
'running' => 'warning',
|
||||
'completed' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private static function outcomeColor(?string $outcome): string
|
||||
{
|
||||
return match ($outcome) {
|
||||
'succeeded' => 'success',
|
||||
'partially_succeeded' => 'warning',
|
||||
'failed' => 'danger',
|
||||
'cancelled' => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||
{
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
@ -3,9 +3,62 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,9 +7,11 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
@ -21,84 +23,70 @@ class ListPolicies extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync from Intune')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync from Intune')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->action(function (self $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$requestedTypes = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
config('tenantpilot.supported_policy_types', [])
|
||||
);
|
||||
|
||||
return $user->canSyncTenant($tenant);
|
||||
})
|
||||
->action(function (self $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
sort($requestedTypes);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync',
|
||||
inputs: [
|
||||
'scope' => 'all',
|
||||
'types' => $requestedTypes,
|
||||
],
|
||||
initiator: $user
|
||||
);
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
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();
|
||||
|
||||
$requestedTypes = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
config('tenantpilot.supported_policy_types', [])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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()
|
||||
$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();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
types: $requestedTypes,
|
||||
operationRun: $opRun
|
||||
);
|
||||
});
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to sync policies.')
|
||||
->destructive()
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,14 @@
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
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\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -19,68 +26,132 @@ class VersionsRelationManager extends RelationManager
|
||||
|
||||
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
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('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')
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('restore_to_intune')
|
||||
->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]));
|
||||
}),
|
||||
$restoreToIntune,
|
||||
Actions\ViewAction::make()
|
||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
651
app/Filament/Resources/ProviderConnectionResource.php
Normal file
651
app/Filament/Resources/ProviderConnectionResource.php
Normal file
@ -0,0 +1,651 @@
|
||||
<?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 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 {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return $query->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
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->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,74 @@
|
||||
<?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 [
|
||||
'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,6 +5,9 @@
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
@ -17,6 +20,27 @@ class CreateRestoreRun extends CreateRecord
|
||||
|
||||
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
|
||||
{
|
||||
return RestoreRunResource::getWizardSteps();
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -13,7 +15,7 @@ class ListRestoreRuns extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,15 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Jobs\BulkTenantSyncJob;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -17,10 +21,17 @@
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
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\TenantRole;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -34,6 +45,7 @@
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@ -52,6 +64,86 @@ class TenantResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (static::userCanManageAnyTenant($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', $user->getKey())
|
||||
->whereIn('role', ['owner', 'manager'])
|
||||
->exists();
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $record instanceof Tenant
|
||||
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $record instanceof Tenant
|
||||
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return static::userCanDeleteAnyTenant($user);
|
||||
}
|
||||
|
||||
private static function userCanManageAnyTenant(User $user): bool
|
||||
{
|
||||
return $user->tenantMemberships()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||
}
|
||||
|
||||
private static function userCanDeleteAnyTenant(User $user): bool
|
||||
{
|
||||
return $user->tenantMemberships()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
// ... [Schema Omitted - No Change] ...
|
||||
@ -103,8 +195,15 @@ public static function getEloquentQuery(): Builder
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->pluck('tenants.id');
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
@ -126,12 +225,8 @@ public static function table(Table $table): Table
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('environment')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'prod' => 'danger',
|
||||
'dev' => 'warning',
|
||||
'staging' => 'info',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::TenantEnvironment))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::TenantEnvironment))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('policies_count')
|
||||
->label('Policies')
|
||||
@ -149,9 +244,17 @@ public static function table(Table $table): Table
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('app_status')
|
||||
->badge(),
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->since(),
|
||||
@ -179,206 +282,326 @@ public static function table(Table $table): Table
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
ActionGroup::make([
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Tenant $record): bool {
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Tenant $record): bool {
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canSyncTenant($record);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
// Phase 3: Canonical Operation Run Start
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: ['scope' => 'full'],
|
||||
initiator: auth()->user()
|
||||
);
|
||||
return $user->canAccessTenant($record);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$user = auth()->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()
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$typeNames = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
$supportedTypes,
|
||||
);
|
||||
sort($typeNames);
|
||||
|
||||
$inputs = [
|
||||
'scope' => 'full',
|
||||
'types' => $typeNames,
|
||||
];
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||
$opService->failStaleQueuedRun(
|
||||
$opRun,
|
||||
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||
);
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->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, $record)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPoliciesJob::dispatch($record->getKey(), null, $opRun);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||
->visible(fn (Tenant $record) => $record->isActive()),
|
||||
Actions\EditAction::make(),
|
||||
Actions\RestoreAction::make()
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->after(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
}),
|
||||
Actions\Action::make('makeCurrent')
|
||||
->label('Make current')
|
||||
->color('success')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record) => $record->isActive() && ! $record->is_current)
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$record->makeCurrent();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.current_set',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Current tenant updated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
) {
|
||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
static::rbacAction(),
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record) => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (?Tenant $record) => $record?->trashed())
|
||||
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
||||
if ($record === null) {
|
||||
return;
|
||||
}
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$tenant = Tenant::withTrashed()->find($record->id);
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
if (! $tenant?->trashed()) {
|
||||
Notification::make()
|
||||
->title('Tenant must be archived first')
|
||||
->danger()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
||||
->action(function (?Tenant $record, AuditLogger $auditLogger): void {
|
||||
if ($record === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$user = auth()->user();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.force_deleted',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
|
||||
);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant->forceDelete();
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::withTrashed()->find($record->id);
|
||||
|
||||
if (! $tenant?->trashed()) {
|
||||
Notification::make()
|
||||
->title('Tenant must be archived first')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.force_deleted',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
|
||||
);
|
||||
|
||||
$tenant->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
]),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\BulkAction::make('syncSelected')
|
||||
@ -386,35 +609,45 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (): bool {
|
||||
->visible(fn (): bool => auth()->user() instanceof User)
|
||||
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||
->disabled(function (Collection $records): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->tenants()
|
||||
->whereIn('role', [
|
||||
TenantRole::Owner->value,
|
||||
TenantRole::Manager->value,
|
||||
TenantRole::Operator->value,
|
||||
])
|
||||
->exists();
|
||||
if ($records->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $records
|
||||
->filter(fn ($record) => $record instanceof Tenant)
|
||||
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
})
|
||||
->authorize(function (): bool {
|
||||
->tooltip(function (Collection $records): ?string {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
return $user->tenants()
|
||||
->whereIn('role', [
|
||||
TenantRole::Owner->value,
|
||||
TenantRole::Manager->value,
|
||||
TenantRole::Operator->value,
|
||||
])
|
||||
->exists();
|
||||
if ($records->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$isDenied = $records
|
||||
->filter(fn ($record) => $record instanceof Tenant)
|
||||
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
|
||||
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
||||
})
|
||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
@ -423,9 +656,12 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$eligible = $records
|
||||
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
|
||||
->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant));
|
||||
->filter(fn (Tenant $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
|
||||
if ($eligible->isEmpty()) {
|
||||
Notification::make()
|
||||
@ -501,35 +737,26 @@ public static function infolist(Schema $schema): Schema
|
||||
Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'active' => 'success',
|
||||
'inactive' => 'gray',
|
||||
'suspended' => 'warning',
|
||||
'error' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
||||
Infolists\Components\TextEntry::make('app_status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'ok', 'configured' => 'success',
|
||||
'pending' => 'warning',
|
||||
'error' => 'danger',
|
||||
'requires_consent' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
|
||||
Infolists\Components\TextEntry::make('app_notes')->label('Notes'),
|
||||
Infolists\Components\TextEntry::make('created_at')->dateTime(),
|
||||
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
|
||||
Infolists\Components\TextEntry::make('rbac_status')
|
||||
->label('RBAC status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'ok', 'configured' => 'success',
|
||||
'manual_assignment_required' => 'warning',
|
||||
'error', 'failed' => 'danger',
|
||||
'not_configured' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus)),
|
||||
Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'),
|
||||
Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(),
|
||||
Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
|
||||
@ -558,12 +785,10 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'granted' => 'success',
|
||||
'missing' => 'warning',
|
||||
'error' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
@ -579,6 +804,13 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\TenantMembershipsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function rbacAction(): Actions\Action
|
||||
{
|
||||
// ... [RBAC Action Omitted - No Change] ...
|
||||
@ -664,7 +896,19 @@ public static function rbacAction(): Actions\Action
|
||||
->noSearchResultsMessage('No security groups found')
|
||||
->loadingMessage('Searching groups...'),
|
||||
])
|
||||
->visible(fn (Tenant $record) => $record->isActive())
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->requiresConfirmation()
|
||||
->action(function (
|
||||
array $data,
|
||||
@ -672,6 +916,19 @@ public static function rbacAction(): Actions\Action
|
||||
RbacOnboardingService $service,
|
||||
AuditLogger $auditLogger
|
||||
) {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
||||
$token = Cache::get($cacheKey);
|
||||
|
||||
@ -697,9 +954,7 @@ public static function rbacAction(): Actions\Action
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
|
||||
$result = $service->run($record, $data, $actor, $token);
|
||||
$result = $service->run($record, $data, $user, $token);
|
||||
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
|
||||
@ -4,13 +4,28 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTenant extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$data['workspace_id'] = $workspaceId;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -20,7 +35,7 @@ protected function afterCreate(): void
|
||||
}
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$this->record->getKey() => ['role' => TenantRole::Owner->value],
|
||||
$this->record->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,11 @@
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTenant extends EditRecord
|
||||
@ -14,7 +18,21 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
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
|
||||
{
|
||||
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;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RbacHealthService;
|
||||
use App\Services\Intune\TenantConfigService;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -16,11 +19,25 @@ class ViewTenant extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
TenantArchivedBanner::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
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')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
@ -48,30 +65,40 @@ protected function getHeaderActions(): array
|
||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
TenantResource::rbacAction(),
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record) => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'internal_tenant_id' => (int) $record->getKey(),
|
||||
'tenant_guid' => (string) $record->tenant_id,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
])
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
@ -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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages;
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class RepairWorkspaceOwners extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static ?string $navigationLabel = 'Repair workspace owners';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
|
||||
|
||||
protected string $view = 'filament.system.pages.repair-workspace-owners';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$breakGlass = app(BreakGlassSession::class);
|
||||
|
||||
return [
|
||||
Action::make('assign_owner')
|
||||
->label('Assign owner (break-glass)')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Assign workspace owner')
|
||||
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
|
||||
->form([
|
||||
Select::make('workspace_id')
|
||||
->label('Workspace')
|
||||
->required()
|
||||
->searchable()
|
||||
->getSearchResultsUsing(function (string $search): array {
|
||||
return Workspace::query()
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orderBy('name')
|
||||
->limit(25)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
})
|
||||
->getOptionLabelUsing(function ($value): ?string {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey((int) $value)->value('name');
|
||||
}),
|
||||
|
||||
Select::make('target_user_id')
|
||||
->label('User')
|
||||
->required()
|
||||
->searchable()
|
||||
->getSearchResultsUsing(function (string $search): array {
|
||||
return User::query()
|
||||
->where('email', 'like', "%{$search}%")
|
||||
->orderBy('email')
|
||||
->limit(25)
|
||||
->pluck('email', 'id')
|
||||
->all();
|
||||
})
|
||||
->getOptionLabelUsing(function ($value): ?string {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::query()->whereKey((int) $value)->value('email');
|
||||
}),
|
||||
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
|
||||
$platformUser = auth('platform')->user();
|
||||
|
||||
if (! $platformUser instanceof PlatformUser) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $breakGlass->isActive()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = (int) ($data['workspace_id'] ?? 0);
|
||||
$targetUserId = (int) ($data['target_user_id'] ?? 0);
|
||||
$reason = (string) ($data['reason'] ?? '');
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
|
||||
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
|
||||
|
||||
$membership = WorkspaceMembership::query()->firstOrNew([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $targetUser->getKey(),
|
||||
]);
|
||||
|
||||
$fromRole = $membership->exists ? (string) $membership->role : null;
|
||||
|
||||
$membership->forceFill([
|
||||
'role' => WorkspaceRole::Owner->value,
|
||||
])->save();
|
||||
|
||||
$auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'actor_user_id' => (int) $platformUser->getKey(),
|
||||
'target_user_id' => (int) $targetUser->getKey(),
|
||||
'attempted_role' => WorkspaceRole::Owner->value,
|
||||
'from_role' => $fromRole,
|
||||
'reason' => trim($reason),
|
||||
'source' => 'break_glass',
|
||||
],
|
||||
],
|
||||
actor: null,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
actorId: (int) $platformUser->getKey(),
|
||||
actorEmail: $platformUser->email,
|
||||
actorName: $platformUser->name,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Owner assigned')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->disabled(fn (): bool => ! $breakGlass->isActive()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,35 +4,46 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class DashboardKpis extends Widget
|
||||
class DashboardKpis extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.dashboard-kpis';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'openDriftFindings' => 0,
|
||||
'highSeverityDriftFindings' => 0,
|
||||
'activeRuns' => 0,
|
||||
'inventoryActiveRuns' => 0,
|
||||
Stat::make('Open drift findings', 0),
|
||||
Stat::make('High severity drift', 0),
|
||||
Stat::make('Active operations', 0),
|
||||
Stat::make('Inventory active', 0),
|
||||
];
|
||||
}
|
||||
|
||||
@ -63,11 +74,17 @@ protected function getViewData(): array
|
||||
->count();
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'openDriftFindings' => $openDriftFindings,
|
||||
'highSeverityDriftFindings' => $highSeverityDriftFindings,
|
||||
'activeRuns' => $activeRuns,
|
||||
'inventoryActiveRuns' => $inventoryActiveRuns,
|
||||
Stat::make('Open drift findings', $openDriftFindings)
|
||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
||||
Stat::make('High severity drift', $highSeverityDriftFindings)
|
||||
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray')
|
||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
||||
Stat::make('Active operations', $activeRuns)
|
||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
||||
Stat::make('Inventory active', $inventoryActiveRuns)
|
||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ protected function getViewData(): array
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'items' => [],
|
||||
'healthyChecks' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@ -50,6 +51,8 @@ protected function getViewData(): array
|
||||
'title' => 'High severity drift findings',
|
||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
@ -67,6 +70,8 @@ protected function getViewData(): array
|
||||
'title' => 'No drift scan yet',
|
||||
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
} else {
|
||||
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||
@ -76,6 +81,8 @@ protected function getViewData(): array
|
||||
'title' => 'Drift stale',
|
||||
'body' => 'Last drift scan is older than 7 days.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -93,6 +100,8 @@ protected function getViewData(): array
|
||||
'title' => 'Drift generation failed',
|
||||
'body' => 'Investigate the latest failed run.',
|
||||
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
@ -106,12 +115,44 @@ protected function getViewData(): array
|
||||
'title' => 'Operations in progress',
|
||||
'body' => "{$activeRuns} run(s) are active.",
|
||||
'url' => OperationRunLinks::index($tenant),
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
}
|
||||
|
||||
$items = array_slice($items, 0, 5);
|
||||
|
||||
$healthyChecks = [];
|
||||
|
||||
if ($items === []) {
|
||||
$healthyChecks = [
|
||||
[
|
||||
'title' => 'Drift findings look healthy',
|
||||
'body' => 'No high severity drift findings are open.',
|
||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||
'linkLabel' => 'View findings',
|
||||
],
|
||||
[
|
||||
'title' => 'Drift scans are up to date',
|
||||
'body' => $latestDriftSuccess?->completed_at
|
||||
? 'Last drift scan: '.$latestDriftSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
||||
: 'Drift scan history is available in Drift.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
'linkLabel' => 'Open Drift',
|
||||
],
|
||||
[
|
||||
'title' => 'No active operations',
|
||||
'body' => 'Nothing is currently running for this tenant.',
|
||||
'url' => OperationRunLinks::index($tenant),
|
||||
'linkLabel' => 'View operations',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'items' => $items,
|
||||
'healthyChecks' => $healthyChecks,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,46 +4,84 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RecentDriftFindings extends Widget
|
||||
class RecentDriftFindings extends TableWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.recent-drift-findings';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'findings' => collect(),
|
||||
];
|
||||
}
|
||||
return $table
|
||||
->heading('Recent Drift Findings')
|
||||
->query($this->getQuery())
|
||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
||||
->paginated([10])
|
||||
->columns([
|
||||
TextColumn::make('short_id')
|
||||
->label('ID')
|
||||
->state(fn (Finding $record): string => '#'.$record->getKey())
|
||||
->copyable()
|
||||
->copyableState(fn (Finding $record): string => (string) $record->getKey()),
|
||||
TextColumn::make('subject_display_name')
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->limit(40)
|
||||
->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null),
|
||||
TextColumn::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since(),
|
||||
])
|
||||
->recordUrl(fn (Finding $record): ?string => $tenant instanceof Tenant
|
||||
? FindingResource::getUrl('view', ['record' => $record], tenant: $tenant)
|
||||
: null)
|
||||
->emptyStateHeading('No drift findings')
|
||||
->emptyStateDescription('You\'re looking good — no drift findings to review yet.');
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
/**
|
||||
* @return Builder<Finding>
|
||||
*/
|
||||
private function getQuery(): Builder
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
|
||||
|
||||
/** @var Collection<int, Finding> $findings */
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
return Finding::query()
|
||||
->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))
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'findings' => $findings,
|
||||
];
|
||||
->latest('created_at');
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,52 +6,76 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RecentOperations extends Widget
|
||||
class RecentOperations extends TableWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.recent-operations';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'runs' => collect(),
|
||||
'viewRunBaseUrl' => null,
|
||||
];
|
||||
}
|
||||
return $table
|
||||
->heading('Recent Operations')
|
||||
->query($this->getQuery())
|
||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
||||
->paginated([10])
|
||||
->columns([
|
||||
TextColumn::make('short_id')
|
||||
->label('Run')
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey())
|
||||
->copyable()
|
||||
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->limit(40)
|
||||
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
TextColumn::make('created_at')
|
||||
->label('Started')
|
||||
->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant
|
||||
? OperationRunLinks::view($record, $tenant)
|
||||
: null)
|
||||
->emptyStateHeading('No operations yet')
|
||||
->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.');
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
/**
|
||||
* @return Builder<OperationRun>
|
||||
*/
|
||||
private function getQuery(): Builder
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
|
||||
|
||||
/** @var Collection<int, OperationRun> $runs */
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->each(function (OperationRun $run) use ($tenant): void {
|
||||
$run->setAttribute('type_label', OperationCatalog::label((string) $run->type));
|
||||
$run->setAttribute('view_url', OperationRunLinks::view($run, $tenant));
|
||||
});
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'runs' => $runs,
|
||||
];
|
||||
return OperationRun::query()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->latest('created_at');
|
||||
}
|
||||
}
|
||||
|
||||
162
app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Normal file
162
app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Normal file
@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Inventory;
|
||||
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Inventory\InventoryKpiBadges;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Inventory\InventorySyncStatusBadge;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class InventoryKpiHeader extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* Inventory KPI aggregation source-of-truth:
|
||||
* - `inventory_items.policy_type`
|
||||
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
|
||||
* - dependency capability via `CoverageCapabilitiesResolver`
|
||||
*
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
Stat::make('Total items', 0),
|
||||
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'),
|
||||
Stat::make('Last inventory sync', '—'),
|
||||
Stat::make('Active ops', 0),
|
||||
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
/** @var array<string, int> $countsByPolicyType */
|
||||
$countsByPolicyType = InventoryItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->selectRaw('policy_type, COUNT(*) as aggregate')
|
||||
->groupBy('policy_type')
|
||||
->pluck('aggregate', 'policy_type')
|
||||
->map(fn ($value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$totalItems = array_sum($countsByPolicyType);
|
||||
|
||||
$restorableItems = 0;
|
||||
$partialItems = 0;
|
||||
$riskItems = 0;
|
||||
|
||||
foreach ($countsByPolicyType as $policyType => $count) {
|
||||
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
|
||||
$restorableItems += $count;
|
||||
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
|
||||
$partialItems += $count;
|
||||
}
|
||||
|
||||
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
|
||||
$riskItems += $count;
|
||||
}
|
||||
}
|
||||
|
||||
$coveragePercent = $totalItems > 0
|
||||
? (int) round(($restorableItems / $totalItems) * 100)
|
||||
: 0;
|
||||
|
||||
$lastRun = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$lastInventorySyncTimeLabel = '—';
|
||||
$lastInventorySyncStatusLabel = '—';
|
||||
$lastInventorySyncStatusColor = 'gray';
|
||||
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
||||
$lastInventorySyncViewUrl = null;
|
||||
|
||||
if ($lastRun instanceof InventorySyncRun) {
|
||||
$timestamp = $lastRun->finished_at ?? $lastRun->started_at;
|
||||
|
||||
if ($timestamp) {
|
||||
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
||||
}
|
||||
|
||||
$status = (string) ($lastRun->status ?? '');
|
||||
|
||||
$badge = InventorySyncStatusBadge::for($status);
|
||||
$lastInventorySyncStatusLabel = $badge['label'];
|
||||
$lastInventorySyncStatusColor = $badge['color'];
|
||||
$lastInventorySyncStatusIcon = $badge['icon'];
|
||||
|
||||
$lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant);
|
||||
}
|
||||
|
||||
$badgeColor = $lastInventorySyncStatusColor;
|
||||
|
||||
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge :color="$badgeColor" size="sm">
|
||||
{{ $statusLabel }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($viewUrl)
|
||||
<x-filament::link :href="$viewUrl" size="sm">
|
||||
View run
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
BLADE, [
|
||||
'badgeColor' => $badgeColor,
|
||||
'statusLabel' => $lastInventorySyncStatusLabel,
|
||||
'viewUrl' => $lastInventorySyncViewUrl,
|
||||
]);
|
||||
|
||||
$activeOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$inventoryOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory.sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$dependenciesItems = 0;
|
||||
foreach ($countsByPolicyType as $policyType => $count) {
|
||||
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
||||
$dependenciesItems += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
Stat::make('Total items', $totalItems),
|
||||
Stat::make('Coverage', $coveragePercent.'%')
|
||||
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
|
||||
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
|
||||
->description(new HtmlString($lastInventorySyncDescription)),
|
||||
Stat::make('Active ops', $activeOps),
|
||||
Stat::make('Inventory ops', $inventoryOps)
|
||||
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
|
||||
];
|
||||
}
|
||||
}
|
||||
136
app/Filament/Widgets/Operations/OperationsKpiHeader.php
Normal file
136
app/Filament/Widgets/Operations/OperationsKpiHeader.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Carbon\CarbonInterval;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class OperationsKpiHeader extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
Stat::make('Total Runs (30 days)', 0),
|
||||
Stat::make('Active Runs', 0),
|
||||
Stat::make('Failed/Partial (7 days)', 0),
|
||||
Stat::make('Avg Duration (7 days)', '—'),
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$totalRuns30Days = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->count();
|
||||
|
||||
$failedOrPartial7Days = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Failed->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->where('completed_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
|
||||
/** @var Collection<int, OperationRun> $recentCompletedRuns */
|
||||
$recentCompletedRuns = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('started_at')
|
||||
->whereNotNull('completed_at')
|
||||
->where('completed_at', '>=', now()->subDays(7))
|
||||
->latest('id')
|
||||
->limit(200)
|
||||
->get(['started_at', 'completed_at']);
|
||||
|
||||
$durations = $recentCompletedRuns
|
||||
->map(function (OperationRun $run): ?int {
|
||||
if (! $run->started_at || ! $run->completed_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = $run->completed_at->diffInSeconds($run->started_at);
|
||||
|
||||
if (is_int($seconds)) {
|
||||
return $seconds;
|
||||
}
|
||||
|
||||
return (int) round((float) $seconds);
|
||||
})
|
||||
->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0)
|
||||
->values();
|
||||
|
||||
$avgDuration7Days = '—';
|
||||
if ($durations->isNotEmpty()) {
|
||||
$avgDurationSeconds = (int) round($durations->avg() ?? 0);
|
||||
$avgDuration7Days = self::formatDurationSeconds($avgDurationSeconds);
|
||||
}
|
||||
|
||||
return [
|
||||
Stat::make('Total Runs (30 days)', $totalRuns30Days),
|
||||
Stat::make('Active Runs', $activeRuns),
|
||||
Stat::make('Failed/Partial (7 days)', $failedOrPartial7Days),
|
||||
Stat::make('Avg Duration (7 days)', $avgDuration7Days),
|
||||
];
|
||||
}
|
||||
|
||||
private static function formatDurationSeconds(int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if ($seconds < 60) {
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
$interval = CarbonInterval::seconds($seconds)->cascade();
|
||||
|
||||
if ($seconds < 3600) {
|
||||
return sprintf('%dm %ds', $interval->minutes, $interval->seconds);
|
||||
}
|
||||
|
||||
return sprintf('%dh %dm', $interval->hours, $interval->minutes);
|
||||
}
|
||||
}
|
||||
28
app/Filament/Widgets/Tenant/TenantArchivedBanner.php
Normal file
28
app/Filament/Widgets/Tenant/TenantArchivedBanner.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantArchivedBanner extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.tenant.tenant-archived-banner';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return [
|
||||
'tenant' => $tenant instanceof Tenant ? $tenant : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
311
app/Http/Controllers/Auth/EntraController.php
Normal file
311
app/Http/Controllers/Auth/EntraController.php
Normal file
@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PostLoginRedirectResolver;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EntraController
|
||||
{
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$clientId = (string) config('services.microsoft.client_id');
|
||||
$clientSecret = (string) config('services.microsoft.client_secret');
|
||||
$redirectUri = (string) config('services.microsoft.redirect');
|
||||
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
|
||||
|
||||
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
||||
}
|
||||
|
||||
$state = (string) Str::uuid();
|
||||
$request->session()->put('entra_state', $state);
|
||||
|
||||
$scopes = implode(' ', ['openid', 'profile', 'email']);
|
||||
|
||||
$url = sprintf(
|
||||
'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s',
|
||||
$authorityTenant,
|
||||
http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_mode' => 'query',
|
||||
'scope' => $scopes,
|
||||
'state' => $state,
|
||||
])
|
||||
);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$expectedState = $request->session()->pull('entra_state');
|
||||
|
||||
if (! is_string($expectedState) || $expectedState === '') {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
|
||||
}
|
||||
|
||||
if ($expectedState !== $request->string('state')->toString()) {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
|
||||
}
|
||||
|
||||
if ($request->string('error')->toString() !== '') {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_user_denied');
|
||||
}
|
||||
|
||||
$code = $request->string('code')->toString();
|
||||
|
||||
if ($code === '') {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
||||
}
|
||||
|
||||
$clientId = (string) config('services.microsoft.client_id');
|
||||
$clientSecret = (string) config('services.microsoft.client_secret');
|
||||
$redirectUri = (string) config('services.microsoft.redirect');
|
||||
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
|
||||
|
||||
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post(
|
||||
sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $authorityTenant),
|
||||
[
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
'redirect_uri' => $redirectUri,
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
||||
}
|
||||
|
||||
$payload = $response->json() ?: [];
|
||||
|
||||
$idToken = $payload['id_token'] ?? null;
|
||||
|
||||
if (! is_string($idToken) || $idToken === '') {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
|
||||
}
|
||||
|
||||
$claims = $this->decodeJwtClaims($idToken);
|
||||
|
||||
$entraTenantId = is_string($claims['tid'] ?? null) ? (string) $claims['tid'] : '';
|
||||
$entraObjectId = is_string($claims['oid'] ?? null) ? (string) $claims['oid'] : '';
|
||||
|
||||
if ($entraTenantId === '' || $entraObjectId === '') {
|
||||
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
|
||||
}
|
||||
|
||||
$email = $this->resolveEmailFromClaims($claims, $entraTenantId, $entraObjectId);
|
||||
$name = $this->resolveNameFromClaims($claims, $email);
|
||||
|
||||
try {
|
||||
$existingUser = User::withTrashed()
|
||||
->where('entra_tenant_id', $entraTenantId)
|
||||
->where('entra_object_id', $entraObjectId)
|
||||
->first();
|
||||
|
||||
if ($existingUser?->trashed()) {
|
||||
return $this->failRedirect(
|
||||
$request,
|
||||
reasonCode: 'user_disabled',
|
||||
entraTenantId: $entraTenantId,
|
||||
entraObjectId: $entraObjectId,
|
||||
userId: (int) $existingUser->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
$isNewUser = $existingUser === null;
|
||||
$user = $existingUser ?? new User;
|
||||
$user->fill([
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_object_id' => $entraObjectId,
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
if ($isNewUser) {
|
||||
$user->password = Str::password(64);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
} catch (\Throwable $exception) {
|
||||
return $this->failRedirect(
|
||||
$request,
|
||||
reasonCode: 'oidc_user_upsert_failed',
|
||||
entraTenantId: $entraTenantId,
|
||||
entraObjectId: $entraObjectId,
|
||||
);
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
Log::info('auth.entra.login', $this->logContext($request, success: true, entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, userId: (int) $user->getKey()));
|
||||
|
||||
$redirectTo = app(PostLoginRedirectResolver::class)->resolve($user);
|
||||
|
||||
return redirect()->to($redirectTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJwtClaims(string $jwt): array
|
||||
{
|
||||
$parts = explode('.', $jwt);
|
||||
|
||||
if (count($parts) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = $this->base64UrlDecode($parts[1]);
|
||||
|
||||
if ($payload === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($payload, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $value): ?string
|
||||
{
|
||||
$value = str_replace(['-', '_'], ['+', '/'], $value);
|
||||
$padding = strlen($value) % 4;
|
||||
|
||||
if ($padding > 0) {
|
||||
$value .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
|
||||
return $decoded === false ? null : $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function resolveEmailFromClaims(array $claims, string $entraTenantId, string $entraObjectId): string
|
||||
{
|
||||
$candidate = null;
|
||||
|
||||
foreach (['preferred_username', 'email', 'upn'] as $key) {
|
||||
$value = $claims[$key] ?? null;
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$candidate = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
$candidate = sprintf('%s@%s.entra.invalid', $entraObjectId, $entraTenantId);
|
||||
}
|
||||
|
||||
$candidate = strtolower(trim($candidate));
|
||||
|
||||
return Str::limit($candidate, 255, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function resolveNameFromClaims(array $claims, string $email): string
|
||||
{
|
||||
$candidate = $claims['name'] ?? null;
|
||||
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
return Str::limit(trim($candidate), 255, '');
|
||||
}
|
||||
|
||||
$given = $claims['given_name'] ?? null;
|
||||
$family = $claims['family_name'] ?? null;
|
||||
|
||||
if (is_string($given) && is_string($family)) {
|
||||
$full = trim($given.' '.$family);
|
||||
|
||||
if ($full !== '') {
|
||||
return Str::limit($full, 255, '');
|
||||
}
|
||||
}
|
||||
|
||||
return Str::limit($email, 255, '');
|
||||
}
|
||||
|
||||
private function failRedirect(
|
||||
Request $request,
|
||||
string $reasonCode,
|
||||
?string $entraTenantId = null,
|
||||
?string $entraObjectId = null,
|
||||
?int $userId = null,
|
||||
): RedirectResponse {
|
||||
Log::warning('auth.entra.login', $this->logContext(
|
||||
$request,
|
||||
success: false,
|
||||
reasonCode: $reasonCode,
|
||||
entraTenantId: $entraTenantId,
|
||||
entraObjectId: $entraObjectId,
|
||||
userId: $userId,
|
||||
));
|
||||
|
||||
return redirect()
|
||||
->to('/admin/login')
|
||||
->with('error', 'Authentication failed. Please try again.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success:bool,reason_code?:string,user_id?:int,entra_tenant_id?:string,entra_object_id_hash?:string,correlation_id:string,timestamp:string}
|
||||
*/
|
||||
private function logContext(
|
||||
Request $request,
|
||||
bool $success,
|
||||
?string $reasonCode = null,
|
||||
?string $entraTenantId = null,
|
||||
?string $entraObjectId = null,
|
||||
?int $userId = null,
|
||||
): array {
|
||||
$correlationId = $request->header('X-Request-Id')
|
||||
?: ($request->hasSession() ? $request->session()->getId() : null)
|
||||
?: (string) Str::uuid();
|
||||
|
||||
$context = [
|
||||
'success' => $success,
|
||||
'correlation_id' => (string) $correlationId,
|
||||
'timestamp' => now()->toISOString(),
|
||||
];
|
||||
|
||||
if ($reasonCode !== null) {
|
||||
$context['reason_code'] = $reasonCode;
|
||||
}
|
||||
|
||||
if ($userId !== null) {
|
||||
$context['user_id'] = $userId;
|
||||
}
|
||||
|
||||
if ($entraTenantId !== null) {
|
||||
$context['entra_tenant_id'] = $entraTenantId;
|
||||
}
|
||||
|
||||
if ($entraObjectId !== null) {
|
||||
$context['entra_object_id_hash'] = hash('sha256', $entraObjectId);
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/SelectTenantController.php
Normal file
72
app/Http/Controllers/SelectTenantController.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class SelectTenantController
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return redirect()->to('/admin/choose-workspace');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($validated['tenant_id'])
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
return redirect()->to(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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
67
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SwitchWorkspaceController
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'workspace_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::query()->whereKey($validated['workspace_id'])->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);
|
||||
|
||||
$tenantsQuery = $user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->where('status', 'active');
|
||||
|
||||
$tenantCount = (int) $tenantsQuery->count();
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
||||
}
|
||||
|
||||
if ($tenantCount === 1) {
|
||||
$tenant = $tenantsQuery->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to(ChooseTenant::getUrl());
|
||||
}
|
||||
}
|
||||
41
app/Http/Middleware/EnsureCorrectGuard.php
Normal file
41
app/Http/Middleware/EnsureCorrectGuard.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureCorrectGuard
|
||||
{
|
||||
/**
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $expectedGuard): Response
|
||||
{
|
||||
$expectedGuard = trim($expectedGuard);
|
||||
|
||||
if ($expectedGuard === '') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$knownGuards = [
|
||||
'web',
|
||||
'platform',
|
||||
];
|
||||
|
||||
foreach ($knownGuards as $guard) {
|
||||
if ($guard === $expectedGuard) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (auth($guard)->check()) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
37
app/Http/Middleware/EnsurePlatformCapability.php
Normal file
37
app/Http/Middleware/EnsurePlatformCapability.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsurePlatformCapability
|
||||
{
|
||||
/**
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $capability): Response
|
||||
{
|
||||
$capability = trim($capability);
|
||||
|
||||
if ($capability === '') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if ($user === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows($capability)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal file
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkspaceMember
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$workspaceParam = $request->route()?->parameter('workspace');
|
||||
|
||||
$workspace = $workspaceParam instanceof Workspace
|
||||
? $workspaceParam
|
||||
: (is_scalar($workspaceParam)
|
||||
? app(WorkspaceResolver::class)->resolve((string) $workspaceParam)
|
||||
: null);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceContext $context */
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
if (! $context->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, $request);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal file
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkspaceSelected
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$routeName = $request->route()?->getName();
|
||||
|
||||
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if (str_starts_with($path, '/admin/t/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/** @var WorkspaceContext $context */
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
|
||||
|
||||
if ($workspace !== null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
||||
|
||||
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
||||
? $membershipQuery
|
||||
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||
->whereNull('workspaces.archived_at')
|
||||
->exists()
|
||||
: $membershipQuery->exists();
|
||||
|
||||
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
||||
|
||||
return new HttpResponse('', 302, ['Location' => $target]);
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,13 @@
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
@ -94,7 +96,7 @@ public function handle(
|
||||
|
||||
$user = User::query()->whereKey($this->userId)->first();
|
||||
|
||||
if (! $user instanceof User || ! $user->canSyncTenant($tenant)) {
|
||||
if (! $user instanceof User || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
|
||||
151
app/Jobs/ProviderComplianceSnapshotJob.php
Normal file
151
app/Jobs/ProviderComplianceSnapshotJob.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\MicrosoftComplianceSnapshotService;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ProviderComplianceSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $providerConnectionId,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
public function handle(
|
||||
MicrosoftComplianceSnapshotService $collector,
|
||||
ProviderGateway $gateway,
|
||||
OperationRunService $runs,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->find($this->providerConnectionId);
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
throw new RuntimeException('ProviderConnection not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
$counts = $collector->snapshot($connection);
|
||||
$entraTenantName = $this->resolveEntraTenantName($connection, $gateway);
|
||||
|
||||
if ($entraTenantName !== null) {
|
||||
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
|
||||
$metadata['entra_tenant_name'] = $entraTenantName;
|
||||
$connection->update(['metadata' => $metadata]);
|
||||
}
|
||||
|
||||
if ($this->operationRun instanceof OperationRun) {
|
||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $counts,
|
||||
);
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
|
||||
$reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage());
|
||||
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [[
|
||||
'code' => 'compliance.snapshot.failed',
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message !== '' ? $message : 'Compliance snapshot failed.',
|
||||
]],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveEntraTenantName(ProviderConnection $connection, ProviderGateway $gateway): ?string
|
||||
{
|
||||
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
|
||||
$existing = $metadata['entra_tenant_name'] ?? null;
|
||||
|
||||
if (is_string($existing) && trim($existing) !== '') {
|
||||
return trim($existing);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $gateway->getOrganization($connection);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$displayName = $response->data['displayName'] ?? null;
|
||||
|
||||
return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : null;
|
||||
}
|
||||
|
||||
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$targetScope = $context['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
||||
|
||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
||||
}
|
||||
|
||||
$context['target_scope'] = $targetScope;
|
||||
|
||||
$run->update(['context' => $context]);
|
||||
}
|
||||
}
|
||||
230
app/Jobs/ProviderConnectionHealthCheckJob.php
Normal file
230
app/Jobs/ProviderConnectionHealthCheckJob.php
Normal file
@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use RuntimeException;
|
||||
|
||||
class ProviderConnectionHealthCheckJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $providerConnectionId,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
public function handle(
|
||||
MicrosoftProviderHealthCheck $healthCheck,
|
||||
OperationRunService $runs,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->find($this->providerConnectionId);
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
throw new RuntimeException('ProviderConnection not found.');
|
||||
}
|
||||
|
||||
$result = $healthCheck->check($connection);
|
||||
|
||||
$this->applyHealthResult($connection, $result);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entraTenantName = $this->resolveEntraTenantName($connection, $result);
|
||||
|
||||
if ($entraTenantName !== null) {
|
||||
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
|
||||
$metadata['entra_tenant_name'] = $entraTenantName;
|
||||
$connection->update(['metadata' => $metadata]);
|
||||
}
|
||||
|
||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||
|
||||
$report = VerificationReportWriter::write(
|
||||
run: $this->operationRun,
|
||||
checks: [
|
||||
[
|
||||
'key' => 'provider.connection.check',
|
||||
'title' => 'Provider connection check',
|
||||
'status' => $result->healthy ? 'pass' : 'fail',
|
||||
'severity' => $result->healthy ? 'info' : 'critical',
|
||||
'blocking' => ! $result->healthy,
|
||||
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
|
||||
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
|
||||
'evidence' => array_values(array_filter([
|
||||
[
|
||||
'kind' => 'provider_connection_id',
|
||||
'value' => (int) $connection->getKey(),
|
||||
],
|
||||
[
|
||||
'kind' => 'entra_tenant_id',
|
||||
'value' => (string) $connection->entra_tenant_id,
|
||||
],
|
||||
is_numeric($result->meta['http_status'] ?? null) ? [
|
||||
'kind' => 'http_status',
|
||||
'value' => (int) $result->meta['http_status'],
|
||||
] : null,
|
||||
is_string($result->meta['organization_id'] ?? null) ? [
|
||||
'kind' => 'organization_id',
|
||||
'value' => (string) $result->meta['organization_id'],
|
||||
] : null,
|
||||
])),
|
||||
'next_steps' => $result->healthy
|
||||
? []
|
||||
: [[
|
||||
'label' => 'Review provider connection',
|
||||
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
|
||||
'record' => (int) $connection->getKey(),
|
||||
], tenant: $tenant),
|
||||
]],
|
||||
],
|
||||
],
|
||||
identity: [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
],
|
||||
);
|
||||
|
||||
if ($result->healthy) {
|
||||
$run = $runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [[
|
||||
'code' => 'provider.connection.check.failed',
|
||||
'reason_code' => $result->reasonCode ?? 'unknown_error',
|
||||
'message' => $result->message ?? 'Health check failed.',
|
||||
]],
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
}
|
||||
|
||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
||||
{
|
||||
$existing = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name');
|
||||
|
||||
if (is_string($existing) && trim($existing) !== '') {
|
||||
return trim($existing);
|
||||
}
|
||||
|
||||
$candidate = $result->meta['organization_display_name'] ?? null;
|
||||
|
||||
return is_string($candidate) && trim($candidate) !== '' ? trim($candidate) : null;
|
||||
}
|
||||
|
||||
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$targetScope = $context['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
||||
|
||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
||||
}
|
||||
|
||||
$context['target_scope'] = $targetScope;
|
||||
|
||||
$run->update(['context' => $context]);
|
||||
}
|
||||
|
||||
private function applyHealthResult(ProviderConnection $connection, HealthResult $result): void
|
||||
{
|
||||
$connection->update([
|
||||
'status' => $result->status,
|
||||
'health_status' => $result->healthStatus,
|
||||
'last_health_check_at' => now(),
|
||||
'last_error_reason_code' => $result->healthy ? null : $result->reasonCode,
|
||||
'last_error_message' => $result->healthy ? null : $result->message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
|
||||
{
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counts = $report['summary']['counts'] ?? [];
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::VerificationCompleted->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'counts' => $counts,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
151
app/Jobs/ProviderInventorySyncJob.php
Normal file
151
app/Jobs/ProviderInventorySyncJob.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\MicrosoftProviderInventoryCollector;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ProviderInventorySyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $providerConnectionId,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
public function handle(
|
||||
MicrosoftProviderInventoryCollector $collector,
|
||||
ProviderGateway $gateway,
|
||||
OperationRunService $runs,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->find($this->providerConnectionId);
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
throw new RuntimeException('ProviderConnection not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
$counts = $collector->collect($connection);
|
||||
$entraTenantName = $this->resolveEntraTenantName($connection, $gateway);
|
||||
|
||||
if ($entraTenantName !== null) {
|
||||
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
|
||||
$metadata['entra_tenant_name'] = $entraTenantName;
|
||||
$connection->update(['metadata' => $metadata]);
|
||||
}
|
||||
|
||||
if ($this->operationRun instanceof OperationRun) {
|
||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $counts,
|
||||
);
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
|
||||
$reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage());
|
||||
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [[
|
||||
'code' => 'inventory.sync.failed',
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message !== '' ? $message : 'Inventory sync failed.',
|
||||
]],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveEntraTenantName(ProviderConnection $connection, ProviderGateway $gateway): ?string
|
||||
{
|
||||
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
|
||||
$existing = $metadata['entra_tenant_name'] ?? null;
|
||||
|
||||
if (is_string($existing) && trim($existing) !== '') {
|
||||
return trim($existing);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $gateway->getOrganization($connection);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$displayName = $response->data['displayName'] ?? null;
|
||||
|
||||
return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : null;
|
||||
}
|
||||
|
||||
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$targetScope = $context['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
||||
|
||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
||||
}
|
||||
|
||||
$context['target_scope'] = $targetScope;
|
||||
|
||||
$run->update(['context' => $context]);
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,8 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -135,18 +133,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
resourceId: (string) $run->id,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync completed')
|
||||
->body('Inventory sync finished successfully.')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -190,18 +176,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
resourceId: (string) $run->id,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync completed with errors')
|
||||
->body('Inventory sync finished with some errors. Review the run details for error codes.')
|
||||
->warning()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -243,18 +217,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
resourceId: (string) $run->id,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync skipped')
|
||||
->body('Inventory sync could not start due to locks or concurrency limits.')
|
||||
->warning()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -297,16 +259,5 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
resourceId: (string) $run->id,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync failed')
|
||||
->body('Inventory sync finished with errors.')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\NullGraphClient;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -23,7 +25,7 @@ class SyncPoliciesJob implements ShouldQueue
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, string>|null $types
|
||||
* @param array<int, string>|array<int, array{type: string, platform?: string|null, filter?: string|null}>|null $types
|
||||
* @param array<int, int>|null $policyIds
|
||||
*/
|
||||
public function __construct(
|
||||
@ -42,6 +44,28 @@ public function middleware(): array
|
||||
|
||||
public function handle(PolicySyncService $service, OperationRunService $operationRunService): void
|
||||
{
|
||||
$graph = app(GraphClientInterface::class);
|
||||
|
||||
if (! config('graph.enabled') || $graph instanceof NullGraphClient) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'graph.disabled',
|
||||
'message' => 'Microsoft Graph is not enabled. Set GRAPH_ENABLED=true (and/or GRAPH_TENANT_ID) in .env to use the real Graph client.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Microsoft Graph is not enabled (GRAPH_ENABLED/GRAPH_TENANT_ID missing).');
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($this->tenantId);
|
||||
|
||||
if ($this->policyIds !== null) {
|
||||
@ -117,7 +141,50 @@ public function handle(PolicySyncService $service, OperationRunService $operatio
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
|
||||
if ($this->types !== null) {
|
||||
$supported = array_values(array_filter($supported, fn ($type) => in_array($type['type'], $this->types, true)));
|
||||
$first = $this->types[0] ?? null;
|
||||
$typesLookLikeSupportedConfig = is_array($first) && array_key_exists('type', $first);
|
||||
|
||||
if ($typesLookLikeSupportedConfig) {
|
||||
$supported = array_values(array_filter(
|
||||
$this->types,
|
||||
static fn ($type): bool => is_array($type) && isset($type['type']) && is_string($type['type']) && $type['type'] !== ''
|
||||
));
|
||||
} else {
|
||||
$requestedTypes = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($type): ?string => is_string($type) ? $type : (is_array($type) ? (string) ($type['type'] ?? '') : null),
|
||||
$this->types,
|
||||
), static fn ($type): bool => is_string($type) && $type !== '')));
|
||||
|
||||
$supported = array_values(array_filter(
|
||||
$supported,
|
||||
static fn ($type): bool => is_array($type)
|
||||
&& isset($type['type'])
|
||||
&& is_string($type['type'])
|
||||
&& in_array($type['type'], $requestedTypes, true)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if ($supported === []) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => $this->types === null
|
||||
? 'tenantpilot.supported_policy_types.empty'
|
||||
: 'tenantpilot.supported_policy_types.no_match',
|
||||
'message' => $this->types === null
|
||||
? 'No supported policy types configured (tenantpilot.supported_policy_types is empty).'
|
||||
: 'No requested policy types matched the supported policy type configuration.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->syncPoliciesWithReport($tenant, $supported);
|
||||
|
||||
@ -9,6 +9,11 @@
|
||||
use App\Models\User;
|
||||
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;
|
||||
@ -22,6 +27,7 @@
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BackupSetPolicyPickerTable extends TableComponent
|
||||
@ -55,7 +61,7 @@ public static function externalIdShort(?string $externalId): string
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$backupSet = BackupSet::query()->find($this->backupSetId);
|
||||
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
|
||||
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
|
||||
$existingPolicyIds = $backupSet
|
||||
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
||||
: [];
|
||||
@ -81,11 +87,15 @@ public function table(Table $table): Table
|
||||
TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')),
|
||||
->placeholder('—')
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
TextColumn::make('platform')
|
||||
->label('Platform')
|
||||
->badge()
|
||||
->default('—')
|
||||
->placeholder('—')
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
TextColumn::make('external_id')
|
||||
->label('External ID')
|
||||
@ -107,8 +117,10 @@ public function table(Table $table): Table
|
||||
TextColumn::make('ignored_at')
|
||||
->label('Ignored')
|
||||
->badge()
|
||||
->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray')
|
||||
->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::IgnoredAt))
|
||||
->color(BadgeRenderer::color(BadgeDomain::IgnoredAt))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::IgnoredAt))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::IgnoredAt))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions'))
|
||||
@ -191,7 +203,11 @@ public function table(Table $table): Table
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canSyncTenant($tenant)) {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -238,7 +254,7 @@ public function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->canSyncTenant($tenant)) {
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
Notification::make()
|
||||
->title('Not allowed')
|
||||
->danger()
|
||||
|
||||
76
app/Models/PlatformUser.php
Normal file
76
app/Models/PlatformUser.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class PlatformUser extends Authenticatable implements FilamentUser
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\PlatformUserFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'capabilities',
|
||||
'is_active',
|
||||
'last_login_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'capabilities' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'last_login_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return $panel->getId() === 'system';
|
||||
}
|
||||
|
||||
public function hasCapability(string $capability): bool
|
||||
{
|
||||
$capability = trim($capability);
|
||||
|
||||
if ($capability === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$capabilities = $this->capabilities;
|
||||
|
||||
if (! is_array($capabilities)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($capability, $capabilities, true);
|
||||
}
|
||||
}
|
||||
51
app/Models/ProviderConnection.php
Normal file
51
app/Models/ProviderConnection.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProviderConnection extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'scopes_granted' => 'array',
|
||||
'metadata' => 'array',
|
||||
'last_health_check_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function credential(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
||||
}
|
||||
|
||||
public function makeDefault(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
static::query()
|
||||
->where('tenant_id', $this->tenant_id)
|
||||
->where('provider', $this->provider)
|
||||
->where('is_default', true)
|
||||
->whereKeyNot($this->getKey())
|
||||
->update(['is_default' => false]);
|
||||
|
||||
static::query()
|
||||
->whereKey($this->getKey())
|
||||
->update(['is_default' => true]);
|
||||
});
|
||||
|
||||
$this->refresh();
|
||||
}
|
||||
}
|
||||
27
app/Models/ProviderCredential.php
Normal file
27
app/Models/ProviderCredential.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProviderCredential extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $hidden = [
|
||||
'payload',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'encrypted:array',
|
||||
];
|
||||
|
||||
public function providerConnection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
|
||||
}
|
||||
}
|
||||
@ -7,8 +7,10 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
@ -115,7 +117,7 @@ public function makeCurrent(): void
|
||||
$this->forceFill(['is_current' => true]);
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
public static function current(): ?self
|
||||
{
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
@ -144,6 +146,13 @@ public static function current(): self
|
||||
->where('is_current', true)
|
||||
->first();
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public static function currentOrFail(): self
|
||||
{
|
||||
$tenant = static::current();
|
||||
|
||||
if (! $tenant) {
|
||||
throw new RuntimeException('No current tenant selected.');
|
||||
}
|
||||
@ -151,6 +160,34 @@ public static function current(): self
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?Model
|
||||
{
|
||||
$field ??= $this->getRouteKeyName();
|
||||
|
||||
$query = static::query();
|
||||
|
||||
if ($field === 'external_id') {
|
||||
$query = $query->withTrashed();
|
||||
}
|
||||
|
||||
return $query->where($field, $value)->first();
|
||||
}
|
||||
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function roleMappings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantRoleMapping::class);
|
||||
}
|
||||
|
||||
public function getFilamentName(): string
|
||||
{
|
||||
$environment = strtoupper((string) ($this->environment ?? 'other'));
|
||||
@ -160,8 +197,9 @@ public function getFilamentName(): string
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class)
|
||||
->withPivot('role')
|
||||
return $this->belongsToMany(User::class, 'tenant_memberships')
|
||||
->using(TenantMembership::class)
|
||||
->withPivot(['id', 'role', 'source', 'source_ref', 'created_by_user_id'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
@ -215,6 +253,16 @@ public function permissions(): HasMany
|
||||
return $this->hasMany(TenantPermission::class);
|
||||
}
|
||||
|
||||
public function providerConnections(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProviderConnection::class);
|
||||
}
|
||||
|
||||
public function providerCredentials(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(ProviderCredential::class, ProviderConnection::class, 'tenant_id', 'provider_connection_id');
|
||||
}
|
||||
|
||||
public function graphTenantId(): ?string
|
||||
{
|
||||
return $this->tenant_id ?? $this->external_id;
|
||||
|
||||
40
app/Models/TenantMembership.php
Normal file
40
app/Models/TenantMembership.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class TenantMembership extends Pivot
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $table = 'tenant_memberships';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function createdByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
}
|
||||
54
app/Models/TenantOnboardingSession.php
Normal file
54
app/Models/TenantOnboardingSession.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantOnboardingSession extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'managed_tenant_onboarding_sessions';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'state' => 'array',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function startedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'started_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function updatedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by_user_id');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user