diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md index 99018f3..aca32c9 100644 --- a/.agents/skills/laravel-best-practices/SKILL.md +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -94,7 +94,7 @@ Check sibling files, related controllers, models, or tests for established patte ### 9. Queue & Job Patterns → `rules/queue-jobs.md` - `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` -- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release - Always implement `failed()`; with `retryUntil()`, set `$tries = 0` - `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs - Horizon for complex multi-queue scenarios diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md index 1650564..6112a63 100644 --- a/.agents/skills/laravel-best-practices/rules/architecture.md +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -82,7 +82,7 @@ $this->app->bind(PaymentGateway::class, StripeGateway::class); ## Default Sort by Descending -When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. Incorrect: ```php diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md index eb3ef3e..e65146d 100644 --- a/.agents/skills/laravel-best-practices/rules/caching.md +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -2,7 +2,7 @@ ## Use `Cache::remember()` Instead of Manual Get/Put -Atomic pattern prevents race conditions and removes boilerplate. +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. Incorrect: ```php diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md index 8fd8f53..193155d 100644 --- a/.agents/skills/laravel-best-practices/rules/config.md +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -2,7 +2,7 @@ ## `env()` Only in Config Files -Direct `env()` calls return `null` when config is cached. +Direct `env()` calls may return `null` when config is cached. Incorrect: ```php diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md index bc43f19..47fcf32 100644 --- a/.agents/skills/laravel-best-practices/rules/events-notifications.md +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -29,7 +29,11 @@ class InvoicePaid extends Notification implements ShouldQueue ## Use `afterCommit()` on Notifications in Transactions -Same race condition as events — the queued notification job may run before the transaction commits. +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` ## Route Notification Channels to Dedicated Queues diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md index 0a7876e..fd37ddb 100644 --- a/.agents/skills/laravel-best-practices/rules/http-client.md +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -52,7 +52,7 @@ $response = Http::retry([100, 500, 1000]) Only retry on specific errors: ```php -$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { return $exception instanceof ConnectionException || ($exception instanceof RequestException && $exception->response->serverError()); })->post('https://api.example.com/data'); diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md index c7f6796..2435d9c 100644 --- a/.agents/skills/laravel-best-practices/rules/mail.md +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -10,7 +10,7 @@ A queued mailable dispatched inside a transaction may process before the commit. ## Use `assertQueued()` Not `assertSent()` for Queued Mailables -`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md index d4575aa..f7aa548 100644 --- a/.agents/skills/laravel-best-practices/rules/queue-jobs.md +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -106,25 +106,23 @@ When using time-based retry limits, set `$tries = 0` to avoid premature failure. ```php public $tries = 0; -public function retryUntil(): DateTime +public function retryUntil(): \DateTimeInterface { return now()->addHours(4); } ``` -## Use `WithoutOverlapping::untilProcessing()` +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release -Prevents concurrent execution while allowing new instances to queue. +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. ```php -public function middleware(): array +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing { - return [new WithoutOverlapping($this->product->id)->untilProcessing()]; + // Lock releases when processing begins, not when it finishes } ``` -Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. - ## Use Horizon for Complex Queue Scenarios Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md index e288375..977d136 100644 --- a/.agents/skills/laravel-best-practices/rules/routing.md +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -36,7 +36,8 @@ Use `Route::resource()` or `apiResource()` for RESTful endpoints. ```php Route::resource('posts', PostController::class); -Route::apiResource('api/posts', Api\PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); ``` ## Keep Controllers Thin diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md index 524d47e..909ff91 100644 --- a/.agents/skills/laravel-best-practices/rules/security.md +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -32,7 +32,7 @@ Use policies or gates in controllers. Never skip authorization. Incorrect: ```php -public function update(Request $request, Post $post) +public function update(UpdatePostRequest $request, Post $post) { $post->update($request->validated()); } @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. Incorrect: ```blade @@ -121,7 +121,7 @@ Route::post('/login', LoginController::class)->middleware('throttle:login'); ## Validate File Uploads -Validate MIME type, extension, and size. Never trust client-provided filenames. +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. ```php public function rules(): array diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md index db689bf..67af989 100644 Binary files a/.agents/skills/laravel-best-practices/rules/style.md and b/.agents/skills/laravel-best-practices/rules/style.md differ diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md index d39cc3e..287b083 100644 --- a/.agents/skills/laravel-best-practices/rules/testing.md +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -2,7 +2,7 @@ ## Use `LazilyRefreshDatabase` Over `RefreshDatabase` -`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. ## Use Model Assertions Over Raw Database Assertions diff --git a/.agents/skills/wayfinder-development/SKILL.md b/.agents/skills/wayfinder-development/SKILL.md index fc352e1..0b30645 100644 --- a/.agents/skills/wayfinder-development/SKILL.md +++ b/.agents/skills/wayfinder-development/SKILL.md @@ -1,6 +1,6 @@ --- name: wayfinder-development -description: "Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions." +description: "Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task" license: MIT metadata: author: laravel diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 619061f..77da3f5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,9 +36,6 @@ jobs: - name: Run Pint run: composer lint - - name: Format Frontend - run: npm run format - - name: Lint Frontend run: npm run lint diff --git a/AGENTS.md b/AGENTS.md index b5f7ab6..db0b49c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,12 +32,11 @@ This application is a Laravel application and its main Laravel ecosystems packag This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. +- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features. - `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. -- `wayfinder-development` — Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions. +- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task - `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using ,
, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation. - `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. -- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features. -- `project-verification-discipline` — Always activate for this project when changing Vue or TypeScript files, Laravel models, or Laravel controllers. It enforces final type-checking for Vue/TS changes and stricter migration, factory, seeder, and PHPUnit verification for model and controller work. ## Conventions @@ -48,15 +47,6 @@ This project has domain-specific skills available. You MUST activate the relevan ## Verification Scripts - Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. -- Every change to Vue or TypeScript files must end with `npm run types:check`. -- Every new or modified Laravel model must be checked for the required migration changes. If schema or persisted data shape changes are needed, include the migration in the same task. -- Every new Laravel model must include a factory. -- Every modified Laravel model must keep its factory up to date with the current model shape and required attributes. -- Every new Laravel model must be covered by one or more seeders that use factories to generate dummy data. -- Every modified Laravel model must keep its related seeders up to date when the model shape, required attributes, or relationships change. -- Every new or modified Laravel model must have PHPUnit coverage created or updated. -- Every new or modified Laravel controller must have PHPUnit coverage created or updated. -- When a model or controller is added or changed, run the affected PHPUnit tests before finalizing. ## Application Structure & Architecture @@ -112,7 +102,7 @@ This project has domain-specific skills available. You MUST activate the relevan - Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. - Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` - - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === @@ -177,17 +167,15 @@ This project has domain-specific skills available. You MUST activate the relevan - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + === wayfinder/core rules === # Laravel Wayfinder -Wayfinder generates TypeScript functions for Laravel routes. Import from `@/actions/` (controllers) or `@/routes/` (named routes). - -- IMPORTANT: Activate `wayfinder-development` skill whenever referencing backend routes in frontend components. -- Invokable Controllers: `import StorePost from '@/actions/.../StorePostController'; StorePost()`. -- Parameter Binding: Detects route keys (`{post:slug}`) — `show({ slug: "my-post" })`. -- Query Merging: `show(1, { mergeQuery: { page: 2, sort: null } })` merges with current URL, `null` removes params. -- Inertia: Use `.form()` with `` component or `form.submit(store())` with useForm. +Use Wayfinder to generate TypeScript functions for Laravel routes. Import from `@/actions/` (controllers) or `@/routes/` (named routes). === pint/core rules === @@ -219,7 +207,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti # Inertia + Vue Vue components must have a single root element. - - IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. diff --git a/app/Actions/Permissions/PermissionService.php b/app/Actions/Permissions/PermissionService.php new file mode 100644 index 0000000..5ab9acf --- /dev/null +++ b/app/Actions/Permissions/PermissionService.php @@ -0,0 +1,343 @@ + $permissions + */ + public function sync(Model $assignee, ?Model $scope, array $permissions): void + { + $normalizedPermissions = $this->normalizePermissions($permissions, $assignee, $scope); + + DB::transaction(function () use ($assignee, $normalizedPermissions, $scope): void { + $this->assignmentQuery($assignee, $scope)->delete(); + + if ($normalizedPermissions === []) { + return; + } + + PermissionAssignment::query()->insert( + array_map( + fn (Permission $permission): array => [ + 'permission' => $permission->value, + 'assignee_id' => $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + 'created_at' => now(), + 'scope_id' => $scope?->getKey(), + 'scope_type' => $scope?->getMorphClass(), + 'updated_at' => now(), + ], + $normalizedPermissions, + ), + ); + }); + } + + public function grant(Model $assignee, Permission|string $permission, ?Model $scope = null): void + { + $normalizedPermission = $this->normalizePermission($permission); + $this->assertAssignmentContext($normalizedPermission, $assignee, $scope); + + $alreadyExists = $this->assignmentQuery($assignee, $scope) + ->where('permission', $normalizedPermission->value) + ->exists(); + + if ($alreadyExists) { + return; + } + + PermissionAssignment::query()->create([ + 'permission' => $normalizedPermission->value, + 'assignee_id' => $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + 'scope_id' => $scope?->getKey(), + 'scope_type' => $scope?->getMorphClass(), + ]); + } + + public function revoke(Model $assignee, Permission|string $permission, ?Model $scope = null): void + { + $normalizedPermission = $this->normalizePermission($permission); + $this->assertAssignmentContext($normalizedPermission, $assignee, $scope); + + $this->assignmentQuery($assignee, $scope) + ->where('permission', $normalizedPermission->value) + ->delete(); + } + + public function allows(User $user, Permission|string $permission, ?Model $scope = null): bool + { + $requiredPermission = $this->normalizePermission($permission); + + return collect($this->effectivePermissions($user, $scope)) + ->contains(fn (Permission $effectivePermission): bool => $effectivePermission === $requiredPermission); + } + + /** + * @return array + */ + public function effectivePermissions(User $user, ?Model $scope = null): array + { + if ($this->hasGlobalAdmin($user)) { + return Permission::cases(); + } + + /** @var Collection $permissions */ + $permissions = $this->relevantAssignments($user, $scope) + ->map(fn (PermissionAssignment $assignment): Permission => $assignment->permission) + ->values(); + + return $this->expandPermissions($permissions->all()); + } + + /** + * @return array + */ + public function permissionOptions(Model|string $assignee, Model|string|null $scope): array + { + return Permission::options( + Permission::assignableFor($assignee, $scope), + ); + } + + /** + * @return array> + */ + public function assignmentMap(string $assigneeClass, ?Model $scope = null): array + { + /** @var Model $assignee */ + $assignee = new $assigneeClass; + + /** @var Collection $assignments */ + $assignments = PermissionAssignment::query() + ->select(['assignee_id', 'permission']) + ->where('assignee_type', $assignee->getMorphClass()) + ->tap(fn (Builder $query): Builder => $this->applyScopeConstraint($query, $scope)) + ->orderBy('permission') + ->get(); + + return $assignments + ->groupBy(fn (PermissionAssignment $assignment): int => (int) $assignment->assignee_id) + ->map(fn (Collection $group): array => $group + ->map(fn (PermissionAssignment $assignment): string => $assignment->permission->value) + ->values() + ->all()) + ->all(); + } + + /** + * @return array + */ + public function viewableProjectIds(User $user): array + { + if ($this->hasGlobalAdmin($user)) { + return Project::query() + ->pluck('id') + ->map(fn (mixed $projectId): int => (int) $projectId) + ->all(); + } + + /** @var Collection $assignments */ + $assignments = PermissionAssignment::query() + ->select(['scope_id', 'permission']) + ->where('scope_type', (new Project)->getMorphClass()) + ->where(function (Builder $query) use ($user): void { + $query->where(function (Builder $directAssignmentsQuery) use ($user): void { + $directAssignmentsQuery + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()); + }); + + $teamIds = $user->teams() + ->pluck('teams.id') + ->map(fn (mixed $teamId): int => (int) $teamId) + ->all(); + + if ($teamIds !== []) { + $query->orWhere(function (Builder $teamAssignmentsQuery) use ($teamIds): void { + $teamAssignmentsQuery + ->where('assignee_type', (new Team)->getMorphClass()) + ->whereIn('assignee_id', $teamIds); + }); + } + }) + ->get(); + + return $assignments + ->filter(fn (PermissionAssignment $assignment): bool => $assignment->permission->includes(Permission::ProjectRead)) + ->pluck('scope_id') + ->filter() + ->map(fn (mixed $projectId): int => (int) $projectId) + ->unique() + ->values() + ->all(); + } + + /** + * @return array + */ + public function manageableTeamIds(User $user): array + { + if ($this->hasGlobalAdmin($user)) { + return Team::query() + ->pluck('id') + ->map(fn (mixed $teamId): int => (int) $teamId) + ->all(); + } + + /** @var Collection $assignments */ + $assignments = PermissionAssignment::query() + ->select(['scope_id', 'permission']) + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()) + ->where('scope_type', (new Team)->getMorphClass()) + ->get(); + + return $assignments + ->filter(fn (PermissionAssignment $assignment): bool => $assignment->permission->includes(Permission::TeamManage)) + ->pluck('scope_id') + ->filter() + ->map(fn (mixed $teamId): int => (int) $teamId) + ->unique() + ->values() + ->all(); + } + + public function canManageAnyTeam(User $user): bool + { + return $this->manageableTeamIds($user) !== []; + } + + private function hasGlobalAdmin(User $user): bool + { + return PermissionAssignment::query() + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()) + ->whereNull('scope_type') + ->whereNull('scope_id') + ->where('permission', Permission::GlobalAdmin->value) + ->exists(); + } + + /** + * @return Collection + */ + private function relevantAssignments(User $user, ?Model $scope): Collection + { + $query = PermissionAssignment::query(); + $this->applyScopeConstraint($query, $scope); + + $query->where(function (Builder $permissionQuery) use ($scope, $user): void { + $permissionQuery->where(function (Builder $directAssignmentsQuery) use ($user): void { + $directAssignmentsQuery + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()); + }); + + if ($scope instanceof Project) { + $teamIds = $user->teams() + ->pluck('teams.id') + ->map(fn (mixed $teamId): int => (int) $teamId) + ->all(); + + if ($teamIds !== []) { + $permissionQuery->orWhere(function (Builder $teamAssignmentsQuery) use ($teamIds): void { + $teamAssignmentsQuery + ->where('assignee_type', (new Team)->getMorphClass()) + ->whereIn('assignee_id', $teamIds); + }); + } + } + }); + + return $query->get(); + } + + /** + * @param array $permissions + * @return array + */ + private function normalizePermissions(array $permissions, Model $assignee, ?Model $scope): array + { + return collect($permissions) + ->map(fn (Permission|string $permission): Permission => $this->normalizePermission($permission)) + ->each(fn (Permission $permission): bool => $this->assertAssignmentContext($permission, $assignee, $scope)) + ->unique(fn (Permission $permission): string => $permission->value) + ->values() + ->all(); + } + + private function normalizePermission(Permission|string $permission): Permission + { + if ($permission instanceof Permission) { + return $permission; + } + + return Permission::from(trim($permission)); + } + + private function assertAssignmentContext(Permission $permission, Model $assignee, ?Model $scope): bool + { + if (! $permission->isAssignableTo($assignee) || ! $permission->matchesScope($scope)) { + throw new InvalidArgumentException("The {$permission->value} permission cannot be assigned in this context."); + } + + return true; + } + + private function assignmentQuery(Model $assignee, ?Model $scope): Builder + { + $query = PermissionAssignment::query() + ->where('assignee_type', $assignee->getMorphClass()) + ->where('assignee_id', $assignee->getKey()); + + $this->applyScopeConstraint($query, $scope); + + return $query; + } + + private function applyScopeConstraint(Builder $query, ?Model $scope): Builder + { + if ($scope === null) { + return $query + ->whereNull('scope_type') + ->whereNull('scope_id'); + } + + return $query + ->where('scope_type', $scope->getMorphClass()) + ->where('scope_id', $scope->getKey()); + } + + /** + * @param array $permissions + * @return array + */ + private function expandPermissions(array $permissions): array + { + $expandedPermissions = []; + + foreach ($permissions as $permission) { + $expandedPermissions[$permission->value] = $permission; + + foreach ($permission->impliedPermissions() as $impliedPermission) { + $expandedPermissions[$impliedPermission->value] = $impliedPermission; + } + } + + return array_values($expandedPermissions); + } +} diff --git a/app/Actions/ProjectPermissions/ProjectPermissionService.php b/app/Actions/ProjectPermissions/ProjectPermissionService.php deleted file mode 100644 index 87ba664..0000000 --- a/app/Actions/ProjectPermissions/ProjectPermissionService.php +++ /dev/null @@ -1,247 +0,0 @@ - - */ - public const PERMISSIONS = [ - 'create', - 'view', - 'edit', - 'delete', - ]; - - /** - * @return array - */ - public static function permissions(): array - { - return self::PERMISSIONS; - } - - /** - * @return array - */ - public function permissionOptions(): array - { - return collect(self::PERMISSIONS) - ->map(fn (string $permission): array => [ - 'label' => Str::headline($permission), - 'value' => $permission, - ]) - ->all(); - } - - public function grantAllProjectPermissions(Project $project, User $user): void - { - $this->syncUserPermissions($project, $user, self::PERMISSIONS); - } - - /** - * @param array $permissions - */ - public function syncUserPermissions(Project $project, User $user, array $permissions): void - { - $sanitizedPermissions = $this->sanitizePermissions($permissions); - - DB::transaction(function () use ($project, $user, $sanitizedPermissions): void { - DB::table('project_user_permissions') - ->where('project_id', $project->id) - ->where('user_id', $user->id) - ->delete(); - - if ($sanitizedPermissions === []) { - return; - } - - DB::table('project_user_permissions')->insert( - array_map( - fn (string $permission): array => [ - 'project_id' => $project->id, - 'user_id' => $user->id, - 'permission' => $permission, - 'created_at' => now(), - 'updated_at' => now(), - ], - $sanitizedPermissions, - ), - ); - }); - } - - /** - * @param array $permissions - */ - public function syncTeamPermissions(Project $project, Team $team, array $permissions): void - { - $sanitizedPermissions = $this->sanitizePermissions($permissions); - - DB::transaction(function () use ($project, $team, $sanitizedPermissions): void { - DB::table('project_team_permissions') - ->where('project_id', $project->id) - ->where('team_id', $team->id) - ->delete(); - - if ($sanitizedPermissions === []) { - return; - } - - DB::table('project_team_permissions')->insert( - array_map( - fn (string $permission): array => [ - 'project_id' => $project->id, - 'team_id' => $team->id, - 'permission' => $permission, - 'created_at' => now(), - 'updated_at' => now(), - ], - $sanitizedPermissions, - ), - ); - }); - } - - /** - * @param array $userIds - */ - public function syncTeamMembers(Team $team, array $userIds): void - { - $team->users()->sync($userIds); - } - - public function userHasPermission(User $user, Project $project, string $permission): bool - { - if ($user->is_admin) { - return true; - } - - $sanitizedPermission = Str::lower(trim($permission)); - - if (! in_array($sanitizedPermission, self::PERMISSIONS, true)) { - return false; - } - - $hasDirectPermission = DB::table('project_user_permissions') - ->where('project_id', $project->id) - ->where('user_id', $user->id) - ->where('permission', $sanitizedPermission) - ->exists(); - - if ($hasDirectPermission) { - return true; - } - - return DB::table('project_team_permissions') - ->join('team_user', 'team_user.team_id', '=', 'project_team_permissions.team_id') - ->where('project_team_permissions.project_id', $project->id) - ->where('team_user.user_id', $user->id) - ->where('project_team_permissions.permission', $sanitizedPermission) - ->exists(); - } - - public function userCanViewProject(User $user, Project $project): bool - { - if ($user->is_admin) { - return true; - } - - return in_array($project->id, $this->viewableProjectIds($user), true); - } - - public function userCanManageProjectAccess(User $user, Project $project): bool - { - return $this->userHasPermission($user, $project, 'edit'); - } - - /** - * @return array - */ - public function viewableProjectIds(User $user): array - { - if ($user->is_admin) { - return Project::query()->pluck('id')->all(); - } - - $directProjectIds = DB::table('project_user_permissions') - ->where('user_id', $user->id) - ->pluck('project_id'); - - $teamProjectIds = DB::table('project_team_permissions') - ->join('team_user', 'team_user.team_id', '=', 'project_team_permissions.team_id') - ->where('team_user.user_id', $user->id) - ->pluck('project_team_permissions.project_id'); - - return $directProjectIds - ->merge($teamProjectIds) - ->map(fn (mixed $projectId): int => (int) $projectId) - ->unique() - ->values() - ->all(); - } - - /** - * @return array> - */ - public function userPermissionMap(Project $project): array - { - /** @var Collection $permissions */ - $permissions = DB::table('project_user_permissions') - ->select(['user_id', 'permission']) - ->where('project_id', $project->id) - ->orderBy('permission') - ->get(); - - return $permissions - ->groupBy(fn (object $permission): int => (int) $permission->user_id) - ->map(fn (Collection $group): array => $group - ->pluck('permission') - ->values() - ->all()) - ->all(); - } - - /** - * @return array> - */ - public function teamPermissionMap(Project $project): array - { - /** @var Collection $permissions */ - $permissions = DB::table('project_team_permissions') - ->select(['team_id', 'permission']) - ->where('project_id', $project->id) - ->orderBy('permission') - ->get(); - - return $permissions - ->groupBy(fn (object $permission): int => (int) $permission->team_id) - ->map(fn (Collection $group): array => $group - ->pluck('permission') - ->values() - ->all()) - ->all(); - } - - /** - * @param array $permissions - * @return array - */ - private function sanitizePermissions(array $permissions): array - { - return collect($permissions) - ->map(fn (mixed $permission): string => Str::lower(trim((string) $permission))) - ->filter(fn (string $permission): bool => in_array($permission, self::PERMISSIONS, true)) - ->unique() - ->values() - ->all(); - } -} diff --git a/app/Console/Commands/CreateInitialAdminUser.php b/app/Console/Commands/CreateInitialAdminUser.php new file mode 100644 index 0000000..337a37b --- /dev/null +++ b/app/Console/Commands/CreateInitialAdminUser.php @@ -0,0 +1,34 @@ + $this->argument('name'), + 'email' => $this->argument('email'), + 'password' => Hash::make($this->argument('password')), + ]); + $this->info('User created'); + + $user->permissionAssignments()->create([ + 'permission' => Permission::GlobalAdmin, + ]); + + return self::SUCCESS; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php new file mode 100644 index 0000000..976023c --- /dev/null +++ b/app/Enums/Permission.php @@ -0,0 +1,173 @@ + 'Global admin', + self::ProjectCreate => 'Project create', + self::ProjectRead => 'Project read', + self::ProjectWrite => 'Project write', + self::ProjectAdmin => 'Project admin', + self::TeamCreate => 'Team create', + self::TeamInvite => 'Team invite', + self::TeamManage => 'Team manage', + self::TeamAdmin => 'Team admin', + }; + } + + public function scope(): PermissionScope + { + return match ($this) { + self::GlobalAdmin, self::ProjectCreate, self::TeamCreate => PermissionScope::Global, + self::ProjectRead, self::ProjectWrite, self::ProjectAdmin => PermissionScope::Project, + self::TeamInvite, self::TeamManage, self::TeamAdmin => PermissionScope::Team, + }; + } + + /** + * @return array + */ + public function impliedPermissions(): array + { + return match ($this) { + self::GlobalAdmin => array_values(array_filter( + self::cases(), + fn (self $permission): bool => $permission !== self::GlobalAdmin, + )), + self::ProjectAdmin => [self::ProjectWrite, self::ProjectRead], + self::ProjectWrite => [self::ProjectRead], + self::TeamAdmin => [self::TeamManage, self::TeamInvite], + default => [], + }; + } + + public function includes(self $permission): bool + { + if ($this === $permission) { + return true; + } + + return array_any($this->impliedPermissions(), fn ($impliedPermission) => $impliedPermission->includes($permission)); + + } + + public function isAssignableTo(Model|string $assignee): bool + { + $assigneeClass = $assignee instanceof Model + ? $assignee::class + : $assignee; + + return match ($this->scope()) { + PermissionScope::Global => is_a($assigneeClass, User::class, true), + PermissionScope::Project => is_a($assigneeClass, User::class, true) || is_a($assigneeClass, Team::class, true), + PermissionScope::Team => is_a($assigneeClass, User::class, true), + }; + } + + public function matchesScope(Model|string|null $scope): bool + { + $scopeClass = $scope instanceof Model + ? $scope::class + : $scope; + + return match ($this->scope()) { + PermissionScope::Global => $scopeClass === null, + PermissionScope::Project => is_a($scopeClass, Project::class, true), + PermissionScope::Team => is_a($scopeClass, Team::class, true), + }; + } + + /** + * @param array|null $permissions + * @return array + */ + public static function values(?array $permissions = null): array + { + return array_map( + fn (self $permission): string => $permission->value, + $permissions ?? self::cases(), + ); + } + + /** + * @return array + */ + public static function globalAssignable(): array + { + return [ + self::GlobalAdmin, + self::TeamCreate, + self::ProjectCreate, + ]; + } + + /** + * @return array + */ + public static function projectAssignable(): array + { + return [ + self::ProjectRead, + self::ProjectWrite, + self::ProjectAdmin, + ]; + } + + /** + * @return array + */ + public static function teamAssignable(): array + { + return [ + self::TeamInvite, + self::TeamManage, + self::TeamAdmin, + ]; + } + + /** + * @return array + */ + public static function assignableFor(Model|string $assignee, Model|string|null $scope): array + { + return array_values(array_filter( + self::cases(), + fn (self $permission): bool => $permission->isAssignableTo($assignee) + && $permission->matchesScope($scope), + )); + } + + /** + * @param array $permissions + * @return array + */ + public static function options(array $permissions): array + { + return array_map( + fn (self $permission): array => [ + 'label' => $permission->label(), + 'value' => $permission->value, + ], + $permissions, + ); + } +} diff --git a/app/Enums/PermissionScope.php b/app/Enums/PermissionScope.php new file mode 100644 index 0000000..698423b --- /dev/null +++ b/app/Enums/PermissionScope.php @@ -0,0 +1,10 @@ + $this->permissionService->assignmentMap(User::class), + 'permissionOptions' => $this->permissionService->permissionOptions(User::class, null), 'status' => session('status'), 'users' => User::query() - ->orderByDesc('is_admin') ->orderBy('name') ->get(), ]); } - public function update(UpdateUserAdminStatusRequest $request, User $user): RedirectResponse + public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse { $validated = $request->validated(); - $user->forceFill([ - 'is_admin' => Arr::get($validated, 'is_admin'), - ])->save(); + $permissions = Arr::get($validated, 'permissions', []); - $status = $user->is_admin - ? "{$user->name} now has admin access." - : "{$user->name} no longer has admin access."; + $this->permissionService->sync($user, null, $permissions); - return to_route('admin.users.index')->with('status', $status); + return to_route('admin.users.index') + ->with('status', "{$user->name} global permissions were updated."); } } diff --git a/app/Http/Controllers/ProjectBacklogController.php b/app/Http/Controllers/ProjectBacklogController.php index 86908da..176b64d 100644 --- a/app/Http/Controllers/ProjectBacklogController.php +++ b/app/Http/Controllers/ProjectBacklogController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateProjectTicket; +use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; @@ -16,7 +17,7 @@ class ProjectBacklogController extends Controller { public function __construct( private readonly CreateProjectTicket $createProjectTicket, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function index(Request $request, Project $project): Response @@ -24,14 +25,14 @@ class ProjectBacklogController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); return Inertia::render('projects/Backlog', [ 'boardColumns' => $project->boardColumns()->get(), - 'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), - 'canCreateTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'create'), + 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'project' => $project, 'status' => session('status'), 'ticketPriorities' => $project->ticketPriorities()->get(), @@ -51,7 +52,7 @@ class ProjectBacklogController extends Controller $validated = $request->validated(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'create'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectBoardController.php b/app/Http/Controllers/ProjectBoardController.php index e6bae65..45cf5ae 100644 --- a/app/Http/Controllers/ProjectBoardController.php +++ b/app/Http/Controllers/ProjectBoardController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateTicketWorklogEntry; +use App\Enums\Permission; use App\Http\Requests\MoveProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; @@ -17,7 +18,7 @@ class ProjectBoardController extends Controller { public function __construct( private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function index(Request $request, Project $project): Response @@ -25,7 +26,7 @@ class ProjectBoardController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); @@ -36,7 +37,7 @@ class ProjectBoardController extends Controller ->with(['ticketPriority', 'ticketType']), ]) ->get(), - 'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'project' => $project, 'status' => session('status'), ]); @@ -50,7 +51,7 @@ class ProjectBoardController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectBoardSettingsController.php b/app/Http/Controllers/ProjectBoardSettingsController.php index b6aca5b..cfc0535 100644 --- a/app/Http/Controllers/ProjectBoardSettingsController.php +++ b/app/Http/Controllers/ProjectBoardSettingsController.php @@ -2,7 +2,8 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreBoardColumnRequest; use App\Http\Requests\UpdateBoardColumnRequest; use App\Models\BoardColumn; @@ -18,13 +19,13 @@ use Inertia\Response; class ProjectBoardSettingsController extends Controller { public function __construct( - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); @@ -40,7 +41,7 @@ class ProjectBoardSettingsController extends Controller Project $project, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); @@ -68,7 +69,7 @@ class ProjectBoardSettingsController extends Controller BoardColumn $boardColumn, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); @@ -91,7 +92,7 @@ class ProjectBoardSettingsController extends Controller BoardColumn $boardColumn, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 10ed4fa..a5be4ae 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\SyncProjectPermissionsRequest; @@ -20,7 +21,7 @@ class ProjectController extends Controller { public function __construct( private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function index(Request $request): Response @@ -30,12 +31,13 @@ class ProjectController extends Controller $projectQuery = Project::query() ->orderByDesc('id'); - if (! $user->is_admin) { - $viewableProjectIds = $this->projectPermissionService->viewableProjectIds($user); + if (! $this->permissionService->allows($user, Permission::GlobalAdmin)) { + $viewableProjectIds = $this->permissionService->viewableProjectIds($user); if ($viewableProjectIds === []) { return Inertia::render('projects/Index', [ 'projects' => [], + 'status' => session('status'), ]); } @@ -49,8 +51,13 @@ class ProjectController extends Controller ]); } - public function create(): Response + public function create(Request $request): Response { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectCreate), + 403, + ); + return Inertia::render('projects/Create'); } @@ -58,7 +65,7 @@ class ProjectController extends Controller { $project = Project::query()->create($request->validated()); $this->initializeProjectTicketMetadata->handle($project); - $this->projectPermissionService->grantAllProjectPermissions($project, $request->user()); + $this->permissionService->grant($request->user(), Permission::ProjectAdmin, $project); return to_route('projects.show', $project); } @@ -68,12 +75,12 @@ class ProjectController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); return Inertia::render('projects/Show', [ - 'canManageAccess' => $this->projectPermissionService->userCanManageProjectAccess($user, $project), + 'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 'project' => $project, ]); } @@ -90,16 +97,16 @@ class ProjectController extends Controller { $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); return Inertia::render('projects/Access', [ - 'directPermissions' => $this->projectPermissionService->userPermissionMap($project), - 'permissionOptions' => $this->projectPermissionService->permissionOptions(), + 'directPermissions' => $this->permissionService->assignmentMap(User::class, $project), + 'permissionOptions' => $this->permissionService->permissionOptions(User::class, Project::class), 'project' => $project, 'status' => session('status'), - 'teamPermissions' => $this->projectPermissionService->teamPermissionMap($project), + 'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project), 'teams' => Team::query() ->with('users') ->orderBy('name') @@ -125,14 +132,14 @@ class ProjectController extends Controller ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $permissions = Arr::get($validated, 'permissions', []); - $this->projectPermissionService->syncUserPermissions($project, $user, $permissions); + $this->permissionService->sync($user, $project, $permissions); return to_route('projects.access', $project) ->with('status', "{$user->name} permissions were updated."); @@ -144,14 +151,14 @@ class ProjectController extends Controller Team $team, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $permissions = Arr::get($validated, 'permissions', []); - $this->projectPermissionService->syncTeamPermissions($project, $team, $permissions); + $this->permissionService->sync($team, $project, $permissions); return to_route('projects.access', $project) ->with('status', "{$team->name} permissions were updated."); diff --git a/app/Http/Controllers/ProjectRepositorySettingsController.php b/app/Http/Controllers/ProjectRepositorySettingsController.php index a2c3914..688e6d1 100644 --- a/app/Http/Controllers/ProjectRepositorySettingsController.php +++ b/app/Http/Controllers/ProjectRepositorySettingsController.php @@ -4,7 +4,8 @@ namespace App\Http\Controllers; use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderManager; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreRepositoryConnectionRequest; use App\Http\Requests\UpdateRepositoryConnectionRequest; use App\Models\Project; @@ -24,14 +25,14 @@ class ProjectRepositorySettingsController extends Controller { public function __construct( private readonly GitProviderManager $gitProviderManager, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response { $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); @@ -66,7 +67,7 @@ class ProjectRepositorySettingsController extends Controller ): RedirectResponse { $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); @@ -96,7 +97,7 @@ class ProjectRepositorySettingsController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); @@ -128,7 +129,7 @@ class ProjectRepositorySettingsController extends Controller RepositoryConnection $repositoryConnection, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketBranchController.php b/app/Http/Controllers/ProjectTicketBranchController.php index 86937c8..672d11d 100644 --- a/app/Http/Controllers/ProjectTicketBranchController.php +++ b/app/Http/Controllers/ProjectTicketBranchController.php @@ -5,7 +5,8 @@ namespace App\Http\Controllers; use App\Actions\Git\Data\GitBranch; use App\Actions\Git\GitProviderManager; use App\Actions\Git\ValidateGitBranchName; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketBranchRequest; use App\Http\Requests\ValidateProjectTicketBranchRequest; use App\Models\Project; @@ -23,7 +24,7 @@ class ProjectTicketBranchController extends Controller public function __construct( private readonly GitProviderManager $gitProviderManager, private readonly ValidateGitBranchName $validateGitBranchName, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function validate( @@ -34,7 +35,7 @@ class ProjectTicketBranchController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); @@ -110,7 +111,7 @@ class ProjectTicketBranchController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index ab6a878..aa28d0c 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -5,8 +5,9 @@ namespace App\Http\Controllers; use App\Actions\Git\BuildSuggestedTicketBranchName; use App\Actions\Git\ResolveTicketBranchMatches; use App\Actions\Git\ResolveTicketPullRequestMatches; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\UpdateProjectTicket; +use App\Enums\Permission; use App\Http\Requests\UpdateProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; @@ -22,7 +23,7 @@ class ProjectTicketController extends Controller { public function __construct( private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches, private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches, private readonly UpdateProjectTicket $updateProjectTicket, @@ -33,14 +34,14 @@ class ProjectTicketController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); $excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)]; return Inertia::render('projects/Ticket', [ - 'canEditTicket' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + 'canEditTicket' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'childTicketTree' => $this->buildChildTicketTree($ticket), 'linkedTickets' => $this->linkedTickets($ticket), 'parentTicketOptions' => Ticket::query() @@ -103,7 +104,7 @@ class ProjectTicketController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketLinkController.php b/app/Http/Controllers/ProjectTicketLinkController.php index da67c21..b558502 100644 --- a/app/Http/Controllers/ProjectTicketLinkController.php +++ b/app/Http/Controllers/ProjectTicketLinkController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateTicketWorklogEntry; +use App\Enums\Permission; use App\Http\Requests\StoreTicketLinkRequest; use App\Models\Project; use App\Models\Ticket; @@ -17,7 +18,7 @@ class ProjectTicketLinkController extends Controller { public function __construct( private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function store( @@ -28,7 +29,7 @@ class ProjectTicketLinkController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); @@ -93,7 +94,7 @@ class ProjectTicketLinkController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index 3827cf9..4913d8c 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Enums\Permission; use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest; @@ -26,7 +27,7 @@ class ProjectTicketMetadataSettingsController extends Controller { public function __construct( private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response @@ -355,7 +356,7 @@ class ProjectTicketMetadataSettingsController extends Controller private function abortUnlessCanManage(Request $request, Project $project): void { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); } diff --git a/app/Http/Controllers/TeamController.php b/app/Http/Controllers/TeamController.php index 68891d7..21424cb 100644 --- a/app/Http/Controllers/TeamController.php +++ b/app/Http/Controllers/TeamController.php @@ -2,12 +2,15 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreTeamRequest; +use App\Http\Requests\SyncTeamUserPermissionsRequest; use App\Http\Requests\UpdateTeamMembersRequest; use App\Models\Team; use App\Models\User; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Arr; use Inertia\Inertia; use Inertia\Response; @@ -15,17 +18,33 @@ use Inertia\Response; class TeamController extends Controller { public function __construct( - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} - public function index(): Response + public function index(Request $request): Response { + $user = $request->user(); + $canCreateTeams = $this->permissionService->allows($user, Permission::TeamCreate); + $manageableTeamIds = $this->permissionService->manageableTeamIds($user); + + abort_unless($canCreateTeams || $manageableTeamIds !== [], 403); + + $teams = Team::query() + ->whereIn('id', $manageableTeamIds) + ->with('users') + ->orderBy('name') + ->get(); + return Inertia::render('teams/Index', [ + 'canCreateTeams' => $canCreateTeams, + 'permissionOptions' => $this->permissionService->permissionOptions(User::class, Team::class), 'status' => session('status'), - 'teams' => Team::query() - ->with('users') - ->orderBy('name') - ->get() + 'teamPermissions' => $teams + ->mapWithKeys(fn (Team $team): array => [ + $team->id => $this->permissionService->assignmentMap(User::class, $team), + ]) + ->all(), + 'teams' => $teams ->map(fn (Team $team): array => [ 'id' => $team->id, 'name' => $team->name, @@ -44,19 +63,42 @@ class TeamController extends Controller { $team = Team::query()->create($request->validated()); - return to_route('admin.teams.index') + return to_route('teams.index') ->with('status', "{$team->name} was created."); } public function updateMembers(UpdateTeamMembersRequest $request, Team $team): RedirectResponse { - $validated = $request->validated(); + abort_unless( + $this->permissionService->allows($request->user(), Permission::TeamManage, $team), + 403, + ); + $validated = $request->validated(); $userIds = Arr::get($validated, 'user_ids', []); - $this->projectPermissionService->syncTeamMembers($team, $userIds); + $team->users()->sync($userIds); - return to_route('admin.teams.index') + return to_route('teams.index') ->with('status', "{$team->name} members were updated."); } + + public function updateUserPermissions( + SyncTeamUserPermissionsRequest $request, + Team $team, + User $user, + ): RedirectResponse { + abort_unless( + $this->permissionService->allows($request->user(), Permission::TeamManage, $team), + 403, + ); + + $validated = $request->validated(); + $permissions = Arr::get($validated, 'permissions', []); + + $this->permissionService->sync($user, $team, $permissions); + + return to_route('teams.index') + ->with('status', "{$user->name} permissions on {$team->name} were updated."); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..d43d301 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,11 +2,17 @@ namespace App\Http\Middleware; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use Illuminate\Http\Request; use Inertia\Middleware; class HandleInertiaRequests extends Middleware { + public function __construct( + private readonly PermissionService $permissionService, + ) {} + /** * The root template that's loaded on the first page visit. * @@ -35,11 +41,18 @@ class HandleInertiaRequests extends Middleware */ public function share(Request $request): array { + $user = $request->user(); + return [ ...parent::share($request), 'name' => config('app.name'), 'auth' => [ - 'user' => $request->user(), + 'permissions' => [ + 'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)), + 'globalAdmin' => $user !== null && $this->permissionService->allows($user, Permission::GlobalAdmin), + 'projectCreate' => $user !== null && $this->permissionService->allows($user, Permission::ProjectCreate), + ], + 'user' => $user, ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', ]; diff --git a/app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php b/app/Http/Requests/Admin/SyncUserGlobalPermissionsRequest.php similarity index 61% rename from app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php rename to app/Http/Requests/Admin/SyncUserGlobalPermissionsRequest.php index dc7ed33..d53dee0 100644 --- a/app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php +++ b/app/Http/Requests/Admin/SyncUserGlobalPermissionsRequest.php @@ -2,10 +2,12 @@ namespace App\Http\Requests\Admin; +use App\Enums\Permission; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; -class UpdateUserAdminStatusRequest extends FormRequest +class SyncUserGlobalPermissionsRequest extends FormRequest { /** * Determine if the user is authorized to make this request. @@ -23,7 +25,12 @@ class UpdateUserAdminStatusRequest extends FormRequest public function rules(): array { return [ - 'is_admin' => ['required', 'boolean'], + 'permissions' => ['nullable', 'array'], + 'permissions.*' => [ + 'string', + 'distinct', + Rule::in(Permission::values(Permission::globalAssignable())), + ], ]; } } diff --git a/app/Http/Requests/DeleteProjectRequest.php b/app/Http/Requests/DeleteProjectRequest.php index 509d917..bec6563 100644 --- a/app/Http/Requests/DeleteProjectRequest.php +++ b/app/Http/Requests/DeleteProjectRequest.php @@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest { public function authorize(): bool { - return $this->user() !== null; + return $this->user()?->can('access-admin') ?? false; } /** diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 479af80..6c7eeb0 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -14,7 +14,7 @@ class StoreProjectRequest extends FormRequest */ public function authorize(): bool { - return $this->user() !== null; + return $this->user()?->can('create-project') ?? false; } protected function prepareForValidation(): void diff --git a/app/Http/Requests/StoreTeamRequest.php b/app/Http/Requests/StoreTeamRequest.php index 3cfb859..2af2e61 100644 --- a/app/Http/Requests/StoreTeamRequest.php +++ b/app/Http/Requests/StoreTeamRequest.php @@ -13,7 +13,7 @@ class StoreTeamRequest extends FormRequest */ public function authorize(): bool { - return $this->user()?->can('access-admin') ?? false; + return $this->user()?->can('create-team') ?? false; } /** diff --git a/app/Http/Requests/SyncProjectPermissionsRequest.php b/app/Http/Requests/SyncProjectPermissionsRequest.php index b3c8bf6..6594521 100644 --- a/app/Http/Requests/SyncProjectPermissionsRequest.php +++ b/app/Http/Requests/SyncProjectPermissionsRequest.php @@ -2,7 +2,7 @@ namespace App\Http\Requests; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Enums\Permission; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -29,7 +29,7 @@ class SyncProjectPermissionsRequest extends FormRequest 'permissions.*' => [ 'string', 'distinct', - Rule::in(ProjectPermissionService::permissions()), + Rule::in(Permission::values(Permission::projectAssignable())), ], ]; } diff --git a/app/Http/Requests/SyncTeamUserPermissionsRequest.php b/app/Http/Requests/SyncTeamUserPermissionsRequest.php new file mode 100644 index 0000000..9a21ff3 --- /dev/null +++ b/app/Http/Requests/SyncTeamUserPermissionsRequest.php @@ -0,0 +1,36 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'permissions' => ['nullable', 'array'], + 'permissions.*' => [ + 'string', + 'distinct', + Rule::in(Permission::values(Permission::teamAssignable())), + ], + ]; + } +} diff --git a/app/Http/Requests/UpdateTeamMembersRequest.php b/app/Http/Requests/UpdateTeamMembersRequest.php index 2c2edd7..8e17244 100644 --- a/app/Http/Requests/UpdateTeamMembersRequest.php +++ b/app/Http/Requests/UpdateTeamMembersRequest.php @@ -13,7 +13,7 @@ class UpdateTeamMembersRequest extends FormRequest */ public function authorize(): bool { - return $this->user()?->can('access-admin') ?? false; + return $this->user() !== null; } /** diff --git a/app/Models/PermissionAssignment.php b/app/Models/PermissionAssignment.php new file mode 100644 index 0000000..dfd4f29 --- /dev/null +++ b/app/Models/PermissionAssignment.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'permission' => Permission::class, + ]; + } + + public function assignee(): MorphTo + { + return $this->morphTo(); + } + + public function scope(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 780748b..b7fec0d 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; #[Fillable(['name', 'key'])] @@ -16,6 +17,13 @@ class Project extends Model /** @use HasFactory */ use HasFactory; + protected static function booted(): void + { + static::deleting(function (self $project): void { + $project->permissionAssignments()->delete(); + }); + } + protected function key(): Attribute { return Attribute::make( @@ -23,6 +31,11 @@ class Project extends Model ); } + public function permissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'scope'); + } + public function boardColumns(): HasMany { return $this->hasMany(BoardColumn::class) diff --git a/app/Models/Team.php b/app/Models/Team.php index 18a1d08..bb54230 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; #[Fillable(['name'])] class Team extends Model @@ -14,6 +15,24 @@ class Team extends Model /** @use HasFactory */ use HasFactory; + protected static function booted(): void + { + static::deleting(function (self $team): void { + $team->permissionAssignments()->delete(); + $team->scopedPermissionAssignments()->delete(); + }); + } + + public function permissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'assignee'); + } + + public function scopedPermissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'scope'); + } + public function users(): BelongsToMany { return $this->belongsToMany(User::class) diff --git a/app/Models/User.php b/app/Models/User.php index 405dffa..8b0576f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -20,6 +21,13 @@ class User extends Authenticatable /** @use HasFactory */ use HasFactory, Notifiable, TwoFactorAuthenticatable; + protected static function booted(): void + { + static::deleting(function (self $user): void { + $user->permissionAssignments()->delete(); + }); + } + /** * Get the attributes that should be cast. * @@ -29,12 +37,16 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', - 'is_admin' => 'boolean', 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', ]; } + public function permissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'assignee'); + } + public function teams(): BelongsToMany { return $this->belongsToMany(Team::class) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 88484df..51e55d9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,13 @@ namespace App\Providers; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; +use App\Models\Project; +use App\Models\Team; use App\Models\User; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -40,7 +45,20 @@ class AppServiceProvider extends ServiceProvider */ protected function configureAuthorization(): void { - Gate::define('access-admin', fn (User $user): bool => $user->is_admin); + Relation::enforceMorphMap([ + 'project' => Project::class, + 'team' => Team::class, + 'user' => User::class, + ]); + + /** @var PermissionService $permissionService */ + $permissionService = $this->app->make(PermissionService::class); + + Gate::define('access-admin', fn (User $user): bool => $permissionService->allows($user, Permission::GlobalAdmin)); + Gate::define('create-project', fn (User $user): bool => $permissionService->allows($user, Permission::ProjectCreate)); + Gate::define('create-team', fn (User $user): bool => $permissionService->allows($user, Permission::TeamCreate)); + Gate::define('manage-teams', fn (User $user): bool => $permissionService->canManageAnyTeam($user)); + Gate::define('view-teams', fn (User $user): bool => Gate::forUser($user)->any(['create-team', 'manage-teams'])); } /** diff --git a/boost.json b/boost.json index 45aa281..622c55d 100644 --- a/boost.json +++ b/boost.json @@ -5,15 +5,12 @@ "guidelines": true, "mcp": true, "nightwatch_mcp": false, - "packages": [ - "laravel/fortify" - ], "sail": false, "skills": [ + "fortify-development", "laravel-best-practices", "wayfinder-development", "inertia-vue-development", - "tailwindcss-development", - "fortify-development" + "tailwindcss-development" ] } diff --git a/composer.lock b/composer.lock index ec34a26..c43dd23 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b55e04a8f415312d9f555292d9e72799", + "content-hash": "c55625a323719bb164dc4b9b763d6ede", "packages": [ { "name": "bacon/bacon-qr-code", - "version": "v3.0.4", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "3feed0e212b8412cc5d2612706744789b0615824" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", - "reference": "3feed0e212b8412cc5d2612706744789b0615824", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { @@ -57,9 +57,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2026-03-16T01:01:30+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "brick/math", @@ -1160,16 +1160,16 @@ }, { "name": "inertiajs/inertia-laravel", - "version": "v2.0.22", + "version": "v2.0.24", "source": { "type": "git", "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "4d5849328d4c64231f886d1422fdc945882f9094" + "reference": "ea345adad12f110edbbc4bef03b69c2374a535d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/4d5849328d4c64231f886d1422fdc945882f9094", - "reference": "4d5849328d4c64231f886d1422fdc945882f9094", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/ea345adad12f110edbbc4bef03b69c2374a535d4", + "reference": "ea345adad12f110edbbc4bef03b69c2374a535d4", "shasum": "" }, "require": { @@ -1227,9 +1227,9 @@ ], "support": { "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.22" + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.24" }, - "time": "2026-03-11T15:51:16+00:00" + "time": "2026-04-10T14:36:44+00:00" }, { "name": "laravel/fortify", @@ -1296,16 +1296,16 @@ }, { "name": "laravel/framework", - "version": "v13.2.0", + "version": "v13.4.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "9e48d1fe933e89de628dafa167d2c5778566d4cf" + "reference": "912de244f88a69742b76e8a2807f6765947776da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/9e48d1fe933e89de628dafa167d2c5778566d4cf", - "reference": "9e48d1fe933e89de628dafa167d2c5778566d4cf", + "url": "https://api.github.com/repos/laravel/framework/zipball/912de244f88a69742b76e8a2807f6765947776da", + "reference": "912de244f88a69742b76e8a2807f6765947776da", "shasum": "" }, "require": { @@ -1423,6 +1423,7 @@ "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", "resend/resend-php": "^1.0", "symfony/cache": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0", @@ -1513,7 +1514,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-24T18:42:09+00:00" + "time": "2026-04-07T13:38:26+00:00" }, { "name": "laravel/prompts", @@ -1576,16 +1577,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.11", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", "shasum": "" }, "require": { @@ -1633,7 +1634,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-07T13:32:18+00:00" }, { "name": "laravel/tinker", @@ -1706,16 +1707,16 @@ }, { "name": "laravel/wayfinder", - "version": "v0.1.14", + "version": "v0.1.16", "source": { "type": "git", "url": "https://github.com/laravel/wayfinder.git", - "reference": "57bbd154ae69b11513d459bb97a0db19a8b7c871" + "reference": "6a5c695dedb77a793bba6dc062bdddea94253517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/wayfinder/zipball/57bbd154ae69b11513d459bb97a0db19a8b7c871", - "reference": "57bbd154ae69b11513d459bb97a0db19a8b7c871", + "url": "https://api.github.com/repos/laravel/wayfinder/zipball/6a5c695dedb77a793bba6dc062bdddea94253517", + "reference": "6a5c695dedb77a793bba6dc062bdddea94253517", "shasum": "" }, "require": { @@ -1765,7 +1766,7 @@ "issues": "https://github.com/laravel/wayfinder/issues", "source": "https://github.com/laravel/wayfinder" }, - "time": "2026-02-24T12:51:07+00:00" + "time": "2026-04-07T17:07:48+00:00" }, { "name": "league/commonmark", @@ -1958,16 +1959,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -2035,9 +2036,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-local", @@ -2431,16 +2432,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -2532,7 +2533,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -3771,16 +3772,16 @@ }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "shasum": "" }, "require": { @@ -3824,7 +3825,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { @@ -3844,20 +3845,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -3914,7 +3915,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -3934,20 +3935,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262" + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", "shasum": "" }, "require": { @@ -3983,7 +3984,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.8" }, "funding": [ { @@ -4003,7 +4004,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4074,16 +4075,16 @@ }, { "name": "symfony/error-handler", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a" + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a", - "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", "shasum": "" }, "require": { @@ -4131,7 +4132,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.0.4" + "source": "https://github.com/symfony/error-handler/tree/v8.0.8" }, "funding": [ { @@ -4151,20 +4152,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", "shasum": "" }, "require": { @@ -4216,7 +4217,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" }, "funding": [ { @@ -4236,7 +4237,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4316,16 +4317,16 @@ }, { "name": "symfony/finder", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" + "reference": "8da41214757b87d97f181e3d14a4179286151007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", "shasum": "" }, "require": { @@ -4360,7 +4361,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.6" + "source": "https://github.com/symfony/finder/tree/v8.0.8" }, "funding": [ { @@ -4380,20 +4381,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:41:02+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "c5ecf7b07408dbc4a87482634307654190954ae8" + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c5ecf7b07408dbc4a87482634307654190954ae8", - "reference": "c5ecf7b07408dbc4a87482634307654190954ae8", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", "shasum": "" }, "require": { @@ -4440,7 +4441,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.7" + "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" }, "funding": [ { @@ -4460,20 +4461,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:40+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7" + "reference": "1770f6818d83b2fddc12185025b93f39a90cb628" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c04721f45723d8ce049fa3eee378b5a505272ac7", - "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628", + "reference": "1770f6818d83b2fddc12185025b93f39a90cb628", "shasum": "" }, "require": { @@ -4544,7 +4545,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.7" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.8" }, "funding": [ { @@ -4564,20 +4565,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:58:46+00:00" + "time": "2026-03-31T21:14:05+00:00" }, { "name": "symfony/mailer", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a8971c86b25ff8557e844f08c1f6207d9b3e614c" + "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a8971c86b25ff8557e844f08c1f6207d9b3e614c", - "reference": "a8971c86b25ff8557e844f08c1f6207d9b3e614c", + "url": "https://api.github.com/repos/symfony/mailer/zipball/ca5f6edaf8780ece814404b58a4482b22b509c56", + "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56", "shasum": "" }, "require": { @@ -4624,7 +4625,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.0.6" + "source": "https://github.com/symfony/mailer/tree/v8.0.8" }, "funding": [ { @@ -4644,20 +4645,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/mime", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b" + "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b", - "reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b", + "url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66", + "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66", "shasum": "" }, "require": { @@ -4710,7 +4711,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.7" + "source": "https://github.com/symfony/mime/tree/v8.0.8" }, "funding": [ { @@ -4730,20 +4731,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:40+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4793,7 +4794,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0" }, "funding": [ { @@ -4813,20 +4814,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", "shasum": "" }, "require": { @@ -4875,7 +4876,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0" }, "funding": [ { @@ -4895,11 +4896,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -4962,7 +4963,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.34.0" }, "funding": [ { @@ -4986,7 +4987,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -5047,7 +5048,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0" }, "funding": [ { @@ -5071,16 +5072,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -5132,7 +5133,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" }, "funding": [ { @@ -5152,20 +5153,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -5216,7 +5217,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.34.0" }, "funding": [ { @@ -5236,20 +5237,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -5296,7 +5297,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.34.0" }, "funding": [ { @@ -5316,20 +5317,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "2c408a6bb0313e6001a83628dc5506100474254e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", + "reference": "2c408a6bb0313e6001a83628dc5506100474254e", "shasum": "" }, "require": { @@ -5376,7 +5377,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0" }, "funding": [ { @@ -5396,20 +5397,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -5459,7 +5460,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.34.0" }, "funding": [ { @@ -5479,20 +5480,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { @@ -5524,7 +5525,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -5544,20 +5545,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/routing", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" + "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "url": "https://api.github.com/repos/symfony/routing/zipball/0de330ec2ea922a7b08ec45615bd51179de7fda4", + "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4", "shasum": "" }, "require": { @@ -5604,7 +5605,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.6" + "source": "https://github.com/symfony/routing/tree/v8.0.8" }, "funding": [ { @@ -5624,7 +5625,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/service-contracts", @@ -5715,16 +5716,16 @@ }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -5781,7 +5782,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -5801,20 +5802,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", "shasum": "" }, "require": { @@ -5874,7 +5875,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.0.8" }, "funding": [ { @@ -5894,7 +5895,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation-contracts", @@ -5980,16 +5981,16 @@ }, { "name": "symfony/uid", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435" + "reference": "f63fa6096a24147283bce4d29327d285326438e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/8b81bd3700f5c1913c22a3266a647aa1bb974435", - "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435", + "url": "https://api.github.com/repos/symfony/uid/zipball/f63fa6096a24147283bce4d29327d285326438e0", + "reference": "f63fa6096a24147283bce4d29327d285326438e0", "shasum": "" }, "require": { @@ -6034,7 +6035,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.0.4" + "source": "https://github.com/symfony/uid/tree/v8.0.8" }, "funding": [ { @@ -6054,20 +6055,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:40:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", - "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", "shasum": "" }, "require": { @@ -6121,7 +6122,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" }, "funding": [ { @@ -6141,7 +6142,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:29+00:00" + "time": "2026-03-31T07:15:36+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6545,16 +6546,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.0", + "version": "v2.4.3", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "a09bebcff238ed92fd123e460b27bd58bed22520" + "reference": "841d52905728cfac9f93c778a1758e740ce9a367" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/a09bebcff238ed92fd123e460b27bd58bed22520", - "reference": "a09bebcff238ed92fd123e460b27bd58bed22520", + "url": "https://api.github.com/repos/laravel/boost/zipball/841d52905728cfac9f93c778a1758e740ce9a367", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367", "shasum": "" }, "require": { @@ -6607,20 +6608,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-23T16:01:08+00:00" + "time": "2026-04-10T15:59:10+00:00" }, { "name": "laravel/mcp", - "version": "v0.6.4", + "version": "v0.6.5", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42" + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", - "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", + "url": "https://api.github.com/repos/laravel/mcp/zipball/583a6282bf0f074d754f7ff5cd1fff9d34244691", + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691", "shasum": "" }, "require": { @@ -6680,7 +6681,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-03-19T12:37:13+00:00" + "time": "2026-03-30T19:17:10+00:00" }, { "name": "laravel/pail", @@ -6893,16 +6894,16 @@ }, { "name": "laravel/sail", - "version": "v1.55.0", + "version": "v1.56.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191" + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/67dc1b72da4e066a2fb54c1c7582fd2f140ea191", - "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191", + "url": "https://api.github.com/repos/laravel/sail/zipball/f43426bb42a1cb7a51a3861d9138063e54766d28", + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28", "shasum": "" }, "require": { @@ -6952,7 +6953,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-03-23T15:56:34+00:00" + "time": "2026-04-01T15:17:32+00:00" }, { "name": "mockery/mockery", @@ -7099,23 +7100,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/b0d8ab95b29c3189aeeb902d81215231df4c1b64", + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.4" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -7123,12 +7124,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.3", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", + "laravel/pint": "^1.29.0", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" }, "type": "library", "extra": { @@ -7191,7 +7192,7 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-04-06T19:25:53+00:00" }, { "name": "phar-io/manifest", @@ -7313,16 +7314,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "a25bde1f8f83849f441ef5713c6466e470872a71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a25bde1f8f83849f441ef5713c6466e470872a71", + "reference": "a25bde1f8f83849f441ef5713c6466e470872a71", "shasum": "" }, "require": { @@ -7331,7 +7332,6 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", "sebastian/environment": "^8.0.3", @@ -7378,7 +7378,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.5" }, "funding": [ { @@ -7398,7 +7398,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-13T04:53:32+00:00" }, { "name": "phpunit/php-file-iterator", @@ -7659,16 +7659,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.19", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "92f7744ca5f5701c9e4b4a60d9e143f2d84956da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/92f7744ca5f5701c9e4b4a60d9e143f2d84956da", + "reference": "92f7744ca5f5701c9e4b4a60d9e143f2d84956da", "shasum": "" }, "require": { @@ -7682,15 +7682,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.5", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/comparator": "^7.1.5", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0.4", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -7737,31 +7737,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.19" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-04-13T05:38:19+00:00" }, { "name": "sebastian/cli-parser", @@ -7834,16 +7818,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63", + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63", "shasum": "" }, "require": { @@ -7902,7 +7886,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5" }, "funding": [ { @@ -7922,7 +7906,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-08T04:43:00+00:00" }, { "name": "sebastian/complexity", @@ -8714,16 +8698,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0" + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", "shasum": "" }, "require": { @@ -8765,7 +8749,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.6" + "source": "https://github.com/symfony/yaml/tree/v8.0.8" }, "funding": [ { @@ -8785,7 +8769,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "theseer/tokenizer", @@ -8844,8 +8828,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/database/factories/PermissionAssignmentFactory.php b/database/factories/PermissionAssignmentFactory.php new file mode 100644 index 0000000..7da6dc9 --- /dev/null +++ b/database/factories/PermissionAssignmentFactory.php @@ -0,0 +1,96 @@ + + */ +class PermissionAssignmentFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'permission' => Permission::ProjectRead->value, + 'assignee_id' => User::factory(), + 'assignee_type' => (new User)->getMorphClass(), + 'scope_id' => Project::factory(), + 'scope_type' => (new Project)->getMorphClass(), + ]; + } + + public function forAssignee(Model $assignee): static + { + return $this->state(fn (): array => [ + 'assignee_id' => $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + ]); + } + + public function forScope(Model $scope): static + { + return $this->state(fn (): array => [ + 'scope_id' => $scope->getKey(), + 'scope_type' => $scope->getMorphClass(), + ]); + } + + public function global(): static + { + return $this->state(fn (): array => [ + 'scope_id' => null, + 'scope_type' => null, + ]); + } + + public function permission(Permission $permission): static + { + return $this->state(fn (): array => [ + 'permission' => $permission->value, + ]); + } + + public function globalAdmin(): static + { + return $this->global()->permission(Permission::GlobalAdmin); + } + + public function projectAdmin(): static + { + return $this->permission(Permission::ProjectAdmin); + } + + public function projectRead(): static + { + return $this->permission(Permission::ProjectRead); + } + + public function projectWrite(): static + { + return $this->permission(Permission::ProjectWrite); + } + + public function teamAdmin(): static + { + return $this->permission(Permission::TeamAdmin); + } + + public function teamInvite(): static + { + return $this->permission(Permission::TeamInvite); + } + + public function teamManage(): static + { + return $this->permission(Permission::TeamManage); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 3148b81..de5d2ba 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\PermissionAssignment; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -28,7 +29,6 @@ class UserFactory extends Factory 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'is_admin' => false, 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, @@ -64,8 +64,11 @@ class UserFactory extends Factory */ public function admin(): static { - return $this->state(fn (array $attributes) => [ - 'is_admin' => true, - ]); + return $this->afterCreating(function (User $user): void { + PermissionAssignment::factory() + ->forAssignee($user) + ->globalAdmin() + ->create(); + }); } } diff --git a/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php b/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php deleted file mode 100644 index 87295b3..0000000 --- a/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('is_admin')->default(false)->after('email_verified_at'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('is_admin'); - }); - } -}; diff --git a/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php b/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php deleted file mode 100644 index f793b0f..0000000 --- a/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php +++ /dev/null @@ -1,31 +0,0 @@ -foreignId('project_id')->constrained()->cascadeOnDelete(); - $table->foreignId('team_id')->constrained()->cascadeOnDelete(); - $table->string('permission'); - $table->timestamps(); - - $table->primary(['project_id', 'team_id', 'permission']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('project_team_permissions'); - } -}; diff --git a/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php b/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php deleted file mode 100644 index d781549..0000000 --- a/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php +++ /dev/null @@ -1,31 +0,0 @@ -foreignId('project_id')->constrained()->cascadeOnDelete(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('permission'); - $table->timestamps(); - - $table->primary(['project_id', 'user_id', 'permission']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('project_user_permissions'); - } -}; diff --git a/database/migrations/2026_04_12_120000_create_permission_assignments_table.php b/database/migrations/2026_04_12_120000_create_permission_assignments_table.php new file mode 100644 index 0000000..ba93815 --- /dev/null +++ b/database/migrations/2026_04_12_120000_create_permission_assignments_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('permission', 50); + $table->string('assignee_type', 32); + $table->unsignedBigInteger('assignee_id'); + $table->string('scope_type', 32)->nullable(); + $table->unsignedBigInteger('scope_id')->nullable(); + $table->timestamps(); + + $table->index(['assignee_type', 'assignee_id']); + $table->index(['scope_type', 'scope_id']); + $table->unique(['permission', 'assignee_type', 'assignee_id', 'scope_type', 'scope_id'], 'permission_assignments_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('permission_assignments'); + } +}; diff --git a/database/seeders/ProjectAccessSeeder.php b/database/seeders/ProjectAccessSeeder.php index 339a657..c4c2500 100644 --- a/database/seeders/ProjectAccessSeeder.php +++ b/database/seeders/ProjectAccessSeeder.php @@ -2,7 +2,8 @@ namespace Database\Seeders; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Enums\Permission; +use App\Models\PermissionAssignment; use App\Models\Project; use App\Models\Team; use App\Models\User; @@ -15,28 +16,33 @@ class ProjectAccessSeeder extends Seeder */ public function run(): void { - $projectPermissionService = app(ProjectPermissionService::class); - - $projects = Project::query()->get(); - $admins = User::query() - ->where('is_admin', true) - ->get(); - - foreach ($admins as $admin) { - foreach ($projects as $project) { - $projectPermissionService->grantAllProjectPermissions($project, $admin); - } - } - $coreProject = Project::query()->where('key', 'CORE')->first(); + $deliveryTeam = Team::query()->where('name', 'Delivery Team')->first(); $platformTeam = Team::query()->where('name', 'Platform Team')->first(); + $testUser = User::query()->where('email', 'test@example.com')->first(); if ($coreProject !== null && $platformTeam !== null) { - $projectPermissionService->syncTeamPermissions($coreProject, $platformTeam, [ - 'create', - 'edit', - 'view', - ]); + PermissionAssignment::factory() + ->forAssignee($platformTeam) + ->forScope($coreProject) + ->projectWrite() + ->create(); + } + + if ($platformTeam !== null && $testUser !== null) { + PermissionAssignment::factory() + ->forAssignee($testUser) + ->forScope($platformTeam) + ->teamManage() + ->create(); + } + + if ($deliveryTeam !== null && $testUser !== null) { + PermissionAssignment::factory() + ->forAssignee($testUser) + ->forScope($deliveryTeam) + ->permission(Permission::TeamInvite) + ->create(); } } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 422ab25..960bdf0 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Enums\Permission; +use App\Models\PermissionAssignment; use App\Models\User; use Illuminate\Database\Seeder; @@ -12,16 +14,27 @@ class UserSeeder extends Seeder */ public function run(): void { - User::factory()->admin()->create([ + $admin = User::factory()->create([ 'name' => 'Admin User', 'email' => 'admin@kanbangit.test', ]); - User::factory()->create([ + PermissionAssignment::factory() + ->forAssignee($admin) + ->globalAdmin() + ->create(); + + $testUser = User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); + PermissionAssignment::factory() + ->forAssignee($testUser) + ->global() + ->permission(Permission::ProjectCreate) + ->create(); + User::factory(8)->create(); } } diff --git a/docs/missing-features.md b/docs/missing-features.md index 3464d57..712fb28 100644 --- a/docs/missing-features.md +++ b/docs/missing-features.md @@ -8,14 +8,6 @@ - app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket... - mehrere actions/quereys in services auslagern -- 03 - - permission models? eg.g syncUserPermissions uses only db table - - mit morph auf perm user (z.b. team oder user) - - Project Permissions Service - - syncTeamMembers(Team $team, array $userIds): void → ist wrong here - - gate statt has permission methods - - permission for creating teams without beeing admind -> teamowner - - team roles - 05 - ticket type remove build in - → add is parentBool to ticketType -> remove system_key (maybe add slug) @@ -38,14 +30,9 @@ - (ui friendly example all following or select) - change Project Backlog Controller → Project Controller - remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create -- 08 - - worklog überarbeiten → type als worklog enum → json je nach enum type - - UpdateProjectTicket aufräumen - 09 - app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null - - - later: - if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type - repository_connections check base Url maybe unnessecary? @@ -53,3 +40,6 @@ - Git prs replace baseUrl, owner, rperoName with GItReprository - git php docu replace @ reutrn with data models - replace Kanbangit with env App Name +- 08 + - worklog überarbeiten → type als worklog enum → json je nach enum type + - UpdateProjectTicket aufräumen diff --git a/package.json b/package.json index 083a961..e221a50 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,6 @@ "build": "vite build", "build:ssr": "vite build && vite build --ssr", "dev": "vite", - "format": "prettier --write resources/", - "format:check": "prettier --check resources/", "lint": "eslint . --fix", "lint:check": "eslint .", "types:check": "vue-tsc --noEmit" diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 5c8302e..a612285 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -23,9 +23,9 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import { dashboard } from '@/routes'; -import { index as adminTeams } from '@/routes/admin/teams'; import { index as adminUsers } from '@/routes/admin/users'; import { index as projectsIndex } from '@/routes/projects'; +import { index as teamsIndex } from '@/routes/teams'; import type { NavItem } from '@/types'; const page = usePage(); @@ -44,16 +44,18 @@ const mainNavItems = computed(() => { }, ]; - if (page.props.auth.user.is_admin) { + if (page.props.auth.permissions.globalAdmin) { items.push({ title: 'Users', href: adminUsers(), icon: Shield, }); + } + if (page.props.auth.permissions.canManageTeams) { items.push({ title: 'Teams', - href: adminTeams(), + href: teamsIndex(), icon: Users, }); } diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index a40a59f..aaf2775 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,6 +1,6 @@