Merge pull request 'codex/permissions-refactoring' (#26) from codex/permissions-refactoring into main
Reviewed-on: OpenSource/KanbanGit#26 Reviewed-by: Eric <eric-peters6@gmx.de>
This commit was merged in pull request #26.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <Link>, <Form>, 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 `<Form>` 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.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Permissions;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Models\PermissionAssignment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionService
|
||||
{
|
||||
/**
|
||||
* @param array<int, Permission|string> $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<int, Permission>
|
||||
*/
|
||||
public function effectivePermissions(User $user, ?Model $scope = null): array
|
||||
{
|
||||
if ($this->hasGlobalAdmin($user)) {
|
||||
return Permission::cases();
|
||||
}
|
||||
|
||||
/** @var Collection<int, Permission> $permissions */
|
||||
$permissions = $this->relevantAssignments($user, $scope)
|
||||
->map(fn (PermissionAssignment $assignment): Permission => $assignment->permission)
|
||||
->values();
|
||||
|
||||
return $this->expandPermissions($permissions->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public function permissionOptions(Model|string $assignee, Model|string|null $scope): array
|
||||
{
|
||||
return Permission::options(
|
||||
Permission::assignableFor($assignee, $scope),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function assignmentMap(string $assigneeClass, ?Model $scope = null): array
|
||||
{
|
||||
/** @var Model $assignee */
|
||||
$assignee = new $assigneeClass;
|
||||
|
||||
/** @var Collection<int, PermissionAssignment> $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<int, int>
|
||||
*/
|
||||
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<int, PermissionAssignment> $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<int, int>
|
||||
*/
|
||||
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<int, PermissionAssignment> $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<int, PermissionAssignment>
|
||||
*/
|
||||
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<int, Permission|string> $permissions
|
||||
* @return array<int, Permission>
|
||||
*/
|
||||
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<int, Permission> $permissions
|
||||
* @return array<int, Permission>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectPermissions;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProjectPermissionService
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const PERMISSIONS = [
|
||||
'create',
|
||||
'view',
|
||||
'edit',
|
||||
'delete',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return self::PERMISSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
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<int, string> $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<int, string> $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<int, int> $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<int, int>
|
||||
*/
|
||||
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<string, array<int, string>>
|
||||
*/
|
||||
public function userPermissionMap(Project $project): array
|
||||
{
|
||||
/** @var Collection<int, object{user_id: int, permission: string}> $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<string, array<int, string>>
|
||||
*/
|
||||
public function teamPermissionMap(Project $project): array
|
||||
{
|
||||
/** @var Collection<int, object{team_id: int, permission: string}> $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<int, string> $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
#[Signature('app:create-initial-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||
#[Description('Command description')]
|
||||
class CreateInitialAdminUser extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
enum Permission: string
|
||||
{
|
||||
case GlobalAdmin = 'global:admin';
|
||||
case ProjectCreate = 'project:create';
|
||||
case ProjectRead = 'project:read';
|
||||
case ProjectWrite = 'project:write';
|
||||
case ProjectAdmin = 'project:admin';
|
||||
case TeamCreate = 'team:create';
|
||||
case TeamInvite = 'team:invite';
|
||||
case TeamManage = 'team:manage';
|
||||
case TeamAdmin = 'team:admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::GlobalAdmin => '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<int, self>
|
||||
*/
|
||||
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<int, self>|null $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(?array $permissions = null): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $permission): string => $permission->value,
|
||||
$permissions ?? self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function globalAssignable(): array
|
||||
{
|
||||
return [
|
||||
self::GlobalAdmin,
|
||||
self::TeamCreate,
|
||||
self::ProjectCreate,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function projectAssignable(): array
|
||||
{
|
||||
return [
|
||||
self::ProjectRead,
|
||||
self::ProjectWrite,
|
||||
self::ProjectAdmin,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function teamAssignable(): array
|
||||
{
|
||||
return [
|
||||
self::TeamInvite,
|
||||
self::TeamManage,
|
||||
self::TeamAdmin,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
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<int, self> $permissions
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public static function options(array $permissions): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $permission): array => [
|
||||
'label' => $permission->label(),
|
||||
'value' => $permission->value,
|
||||
],
|
||||
$permissions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PermissionScope: string
|
||||
{
|
||||
case Global = 'global';
|
||||
case Project = 'project';
|
||||
case Team = 'team';
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateUserAdminStatusRequest;
|
||||
use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -12,28 +13,30 @@ use Inertia\Response;
|
||||
|
||||
class UserManagementController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/users/Index', [
|
||||
'directPermissions' => $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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
+9
-2
@@ -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())),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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())),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SyncTeamUserPermissionsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'permissions' => ['nullable', 'array'],
|
||||
'permissions.*' => [
|
||||
'string',
|
||||
'distinct',
|
||||
Rule::in(Permission::values(Permission::teamAssignable())),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class UpdateTeamMembersRequest extends FormRequest
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Database\Factories\PermissionAssignmentFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
#[Fillable(['permission', 'assignee_type', 'assignee_id', 'scope_type', 'scope_id'])]
|
||||
class PermissionAssignment extends Model
|
||||
{
|
||||
/** @use HasFactory<PermissionAssignmentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'permission' => Permission::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function assignee(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function scope(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
@@ -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<ProjectFactory> */
|
||||
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)
|
||||
|
||||
@@ -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<TeamFactory> */
|
||||
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)
|
||||
|
||||
+13
-1
@@ -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<UserFactory> */
|
||||
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)
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-5
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+240
-256
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Models\PermissionAssignment;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @extends Factory<PermissionAssignment>
|
||||
*/
|
||||
class PermissionAssignmentFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_team_permissions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_user_permissions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('permission_assignments', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<NavItem[]>(() => {
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Shield, Users } from 'lucide-vue-next';
|
||||
import { FolderPlus, Shield, Users } from 'lucide-vue-next';
|
||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -16,7 +16,7 @@ import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
||||
|
||||
defineProps<AdminUsersPageProps>();
|
||||
const props = defineProps<AdminUsersPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
@@ -24,6 +24,9 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
href: adminUsers(),
|
||||
},
|
||||
];
|
||||
|
||||
const hasPermission = (actorId: number, permission: string) =>
|
||||
(props.directPermissions[actorId] ?? []).includes(permission);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +37,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<Heading
|
||||
title="User management"
|
||||
description="Manage application-wide admin access for existing users."
|
||||
description="Assign the global permissions that unlock platform-wide administration and project creation."
|
||||
/>
|
||||
|
||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||
@@ -54,8 +57,8 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CardDescription>
|
||||
Promote trusted users to platform admins or remove admin
|
||||
access when it is no longer needed.
|
||||
Global permissions are assigned directly to users and
|
||||
apply across the entire application.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -77,7 +80,9 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">User</th>
|
||||
<th class="px-4 py-3 font-medium">Email</th>
|
||||
<th class="px-4 py-3 font-medium">Role</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Current access
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right font-medium"
|
||||
>
|
||||
@@ -89,7 +94,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="bg-background"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
@@ -102,19 +107,45 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
{{ user.email }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Badge
|
||||
:variant="
|
||||
user.is_admin
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
"
|
||||
class="gap-1.5"
|
||||
>
|
||||
<Shield class="size-3.5" />
|
||||
{{
|
||||
user.is_admin ? 'Admin' : 'User'
|
||||
}}
|
||||
</Badge>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-if="
|
||||
hasPermission(
|
||||
user.id,
|
||||
'global:admin',
|
||||
)
|
||||
"
|
||||
class="gap-1.5"
|
||||
>
|
||||
<Shield class="size-3.5" />
|
||||
Global admin
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="
|
||||
hasPermission(
|
||||
user.id,
|
||||
'project:create',
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
class="gap-1.5"
|
||||
>
|
||||
<FolderPlus class="size-3.5" />
|
||||
Project create
|
||||
</Badge>
|
||||
<span
|
||||
v-if="
|
||||
(
|
||||
directPermissions[
|
||||
user.id
|
||||
] ?? []
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No direct global permissions
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex justify-end">
|
||||
@@ -124,33 +155,40 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
user.id,
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap justify-end gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="is_admin"
|
||||
:value="
|
||||
user.is_admin
|
||||
? '0'
|
||||
: '1'
|
||||
"
|
||||
/>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${user.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="option.value"
|
||||
:checked="
|
||||
hasPermission(
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{
|
||||
option.label
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<div class="w-full sm:hidden" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
:variant="
|
||||
user.is_admin
|
||||
? 'outline'
|
||||
: 'default'
|
||||
"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{
|
||||
user.is_admin
|
||||
? 'Remove admin'
|
||||
: 'Make admin'
|
||||
}}
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ const hasPermission = (
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="$page.props.auth.user.is_admin"
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -38,7 +38,10 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
description="Create and browse project spaces that will later host boards, backlog, and settings."
|
||||
/>
|
||||
|
||||
<Button as-child>
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
as-child
|
||||
>
|
||||
<Link :href="create()">
|
||||
<Plus class="size-4" />
|
||||
New project
|
||||
|
||||
@@ -107,7 +107,7 @@ const projectAreas = [
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="$page.props.auth.user.is_admin"
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -61,7 +61,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="$page.props.auth.user.is_admin"
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
metadata as metadataSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
ProjectGeneralSettingsPageProps,
|
||||
} from '@/types';
|
||||
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
||||
@@ -124,24 +121,24 @@ const submitDelete = (): void => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-border/70 p-4">
|
||||
<div class="border-border/70 rounded-xl border p-4">
|
||||
<p
|
||||
class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"
|
||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
||||
>
|
||||
Project name
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-semibold text-foreground">
|
||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
||||
{{ project.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 p-4">
|
||||
<div class="border-border/70 rounded-xl border p-4">
|
||||
<p
|
||||
class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"
|
||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
||||
>
|
||||
Project key
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-semibold text-foreground">
|
||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
||||
{{ project.key }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,9 +163,9 @@ const submitDelete = (): void => {
|
||||
|
||||
<CardContent class="space-y-5">
|
||||
<div
|
||||
class="rounded-xl border border-red-200/80 bg-background/80 p-4 dark:border-red-900/60"
|
||||
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
|
||||
>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
<p class="text-foreground text-sm font-medium">
|
||||
Confirm by typing the exact project key:
|
||||
<span class="font-semibold">{{ project.key }}</span>
|
||||
</p>
|
||||
@@ -193,7 +190,7 @@ const submitDelete = (): void => {
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="$page.props.auth.user.is_admin"
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -234,7 +234,7 @@ const submitUpdate = (): void => {
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="$page.props.auth.user.is_admin"
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as teamsIndex } from '@/routes/admin/teams';
|
||||
import { index as teamsIndex } from '@/routes/teams';
|
||||
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
||||
|
||||
defineProps<TeamsIndexPageProps>();
|
||||
const props = defineProps<TeamsIndexPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
@@ -27,6 +27,15 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
href: teamsIndex(),
|
||||
},
|
||||
];
|
||||
|
||||
const hasPermission = (
|
||||
permissions: Record<string, string[]>,
|
||||
actorId: number,
|
||||
permission: string,
|
||||
) => (permissions[actorId] ?? []).includes(permission);
|
||||
|
||||
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
||||
props.teamPermissions[teamId] ?? {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,12 +48,12 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
>
|
||||
<Heading
|
||||
title="Teams"
|
||||
description="Create reusable teams and manage member assignments for inherited project permissions."
|
||||
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
|
||||
/>
|
||||
|
||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||
<Shield class="size-3.5" />
|
||||
Admin-only area
|
||||
{{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -55,8 +64,15 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<div
|
||||
class="grid gap-6"
|
||||
:class="
|
||||
canCreateTeams
|
||||
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
|
||||
: 'xl:grid-cols-1'
|
||||
"
|
||||
>
|
||||
<Card v-if="canCreateTeams">
|
||||
<CardHeader>
|
||||
<CardTitle>Create team</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -121,7 +137,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="pt-6">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<Form
|
||||
v-bind="
|
||||
TeamController.updateMembers.form(team.id)
|
||||
@@ -164,12 +180,173 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
Save members
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div class="space-y-4 border-t pt-6">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-foreground font-medium">
|
||||
Team permissions
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Direct user permissions on this team are
|
||||
stored separately from team membership.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
>
|
||||
<thead
|
||||
class="bg-muted/40 text-muted-foreground text-left"
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 font-medium"
|
||||
>
|
||||
User
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 font-medium"
|
||||
>
|
||||
Current permissions
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right font-medium"
|
||||
>
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-border divide-y">
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="`${team.id}-${user.id}`"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
class="text-foreground font-medium"
|
||||
>
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="option in permissionOptions"
|
||||
v-show="
|
||||
hasPermission(
|
||||
permissionsForTeam(
|
||||
team.id,
|
||||
),
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
:key="`${team.id}-${user.id}-${option.value}-badge`"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="
|
||||
(
|
||||
permissionsForTeam(
|
||||
team.id,
|
||||
)[
|
||||
user.id
|
||||
] ?? []
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No direct team
|
||||
permissions
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
class="flex justify-end"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
TeamController.updateUserPermissions.form(
|
||||
[
|
||||
team.id,
|
||||
user.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap justify-end gap-3"
|
||||
v-slot="{
|
||||
processing,
|
||||
}"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${team.id}-${user.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="
|
||||
option.value
|
||||
"
|
||||
:checked="
|
||||
hasPermission(
|
||||
permissionsForTeam(
|
||||
team.id,
|
||||
),
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
option.label
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="w-full sm:hidden"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="
|
||||
processing
|
||||
"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="teams.length === 0">
|
||||
<CardContent class="text-muted-foreground p-6 text-sm">
|
||||
No teams exist yet.
|
||||
No teams are available for your current permissions.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,14 @@ import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
export type User = UserModel;
|
||||
|
||||
export type AuthPermissions = {
|
||||
canManageTeams: boolean;
|
||||
globalAdmin: boolean;
|
||||
projectCreate: boolean;
|
||||
};
|
||||
|
||||
export type Auth = {
|
||||
permissions: AuthPermissions;
|
||||
user: User;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ export type UserModel = {
|
||||
email: string;
|
||||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { UserGitConnectionModel } from '@/types/models/user-git-connection-
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
export type AdminUsersPageProps = {
|
||||
directPermissions: Record<string, string[]>;
|
||||
permissionOptions: PermissionOption[];
|
||||
users: UserModel[];
|
||||
status?: string | null;
|
||||
};
|
||||
@@ -31,14 +33,14 @@ export type ProjectShowPageProps = {
|
||||
project: ProjectModel;
|
||||
};
|
||||
|
||||
export type ProjectPermissionOption = {
|
||||
export type PermissionOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ProjectAccessPageProps = {
|
||||
directPermissions: Record<string, string[]>;
|
||||
permissionOptions: ProjectPermissionOption[];
|
||||
permissionOptions: PermissionOption[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
teamPermissions: Record<string, string[]>;
|
||||
@@ -47,7 +49,10 @@ export type ProjectAccessPageProps = {
|
||||
};
|
||||
|
||||
export type TeamsIndexPageProps = {
|
||||
canCreateTeams: boolean;
|
||||
permissionOptions: PermissionOption[];
|
||||
status?: string | null;
|
||||
teamPermissions: Record<string, Record<string, string[]>>;
|
||||
teams: TeamModel[];
|
||||
users: UserModel[];
|
||||
};
|
||||
|
||||
+12
-5
@@ -103,6 +103,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
|
||||
->name('projects.settings.metadata.link-types.destroy');
|
||||
});
|
||||
|
||||
Route::get('teams', [TeamController::class, 'index'])
|
||||
->middleware('can:view-teams')
|
||||
->name('teams.index');
|
||||
Route::post('teams', [TeamController::class, 'store'])
|
||||
->middleware('can:create-team')
|
||||
->name('teams.store');
|
||||
Route::patch('teams/{team}/members', [TeamController::class, 'updateMembers'])
|
||||
->name('teams.members.update');
|
||||
Route::patch('teams/{team}/permissions/users/{user}', [TeamController::class, 'updateUserPermissions'])
|
||||
->name('teams.permissions.users.update');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'can:access-admin'])
|
||||
@@ -110,11 +121,7 @@ Route::middleware(['auth', 'verified', 'can:access-admin'])
|
||||
->name('admin.')
|
||||
->group(function () {
|
||||
Route::get('users', [UserManagementController::class, 'index'])->name('users.index');
|
||||
Route::patch('users/{user}/admin-status', [UserManagementController::class, 'update'])->name('users.update');
|
||||
|
||||
Route::get('teams', [TeamController::class, 'index'])->name('teams.index');
|
||||
Route::post('teams', [TeamController::class, 'store'])->name('teams.store');
|
||||
Route::patch('teams/{team}/members', [TeamController::class, 'updateMembers'])->name('teams.members.update');
|
||||
Route::patch('users/{user}/permissions', [UserManagementController::class, 'update'])->name('users.update');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
@@ -27,10 +27,10 @@ class UserManagementControllerTest extends TestCase
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/users/Index')
|
||||
->where('users.0.email', $admin->email)
|
||||
->where('users.1.email', $member->email)
|
||||
->where('users.0.is_admin', true)
|
||||
->where('users.1.is_admin', false),
|
||||
->has('users', 2)
|
||||
->where("directPermissions.{$admin->id}.0", 'global:admin')
|
||||
->where('permissionOptions.0.value', 'global:admin')
|
||||
->where('permissionOptions.1.value', 'project:create'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ class UserManagementControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can update another user's admin access.
|
||||
* Ensure admins can update another user's global permissions.
|
||||
*/
|
||||
public function test_admin_can_update_a_users_admin_access(): void
|
||||
public function test_admin_can_update_a_users_global_permissions(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create([
|
||||
@@ -58,30 +58,72 @@ class UserManagementControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.users.update', $member), [
|
||||
'is_admin' => true,
|
||||
'permissions' => ['global:admin', 'project:create'],
|
||||
])
|
||||
->assertRedirect(route('admin.users.index'))
|
||||
->assertSessionHas('status', 'Regular User now has admin access.');
|
||||
->assertSessionHas('status', 'Regular User global permissions were updated.');
|
||||
|
||||
$this->assertTrue($member->refresh()->is_admin);
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'permission' => 'global:admin',
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'permission' => 'project:create',
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure non-admin users cannot update admin access.
|
||||
* Ensure admins can also clear a user's global permissions.
|
||||
*/
|
||||
public function test_non_admin_cannot_update_a_users_admin_access(): void
|
||||
public function test_admin_can_clear_a_users_global_permissions(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create();
|
||||
$this->syncGlobalPermissions($member, ['global:admin', 'project:create']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.users.update', $member), [
|
||||
'permissions' => [],
|
||||
])
|
||||
->assertRedirect(route('admin.users.index'))
|
||||
->assertSessionHas('status', "{$member->name} global permissions were updated.");
|
||||
|
||||
$this->assertDatabaseMissing('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure non-admin users cannot update global permissions.
|
||||
*/
|
||||
public function test_non_admin_cannot_update_a_users_global_permissions(): void
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$otherUser = User::factory()->create([
|
||||
'is_admin' => false,
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$this->actingAs($member)
|
||||
->patch(route('admin.users.update', $otherUser), [
|
||||
'is_admin' => true,
|
||||
'permissions' => ['global:admin'],
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertFalse($otherUser->refresh()->is_admin);
|
||||
$this->assertDatabaseMissing('permission_assignments', [
|
||||
'assignee_id' => $otherUser->id,
|
||||
'assignee_type' => $otherUser->getMorphClass(),
|
||||
'permission' => 'global:admin',
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Models\PermissionAssignment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PermissionAssignmentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the permission assignment factory creates a user-to-project assignment.
|
||||
*/
|
||||
public function test_permission_assignment_factory_creates_a_project_assignment(): void
|
||||
{
|
||||
$assignment = PermissionAssignment::factory()->create();
|
||||
|
||||
$this->assertInstanceOf(User::class, $assignment->assignee);
|
||||
$this->assertInstanceOf(Project::class, $assignment->scope);
|
||||
$this->assertSame('project:read', $assignment->permission->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure deleting users, teams, and projects removes related permission assignments.
|
||||
*/
|
||||
public function test_related_permission_assignments_are_removed_when_parent_models_are_deleted(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$userAssignment = PermissionAssignment::factory()
|
||||
->forAssignee($user)
|
||||
->globalAdmin()
|
||||
->create();
|
||||
$teamAssignment = PermissionAssignment::factory()
|
||||
->forAssignee($team)
|
||||
->forScope($project)
|
||||
->projectWrite()
|
||||
->create();
|
||||
$teamScopeAssignment = PermissionAssignment::factory()
|
||||
->forAssignee($user)
|
||||
->forScope($team)
|
||||
->teamManage()
|
||||
->create();
|
||||
|
||||
$user->delete();
|
||||
$project->delete();
|
||||
$team->delete();
|
||||
|
||||
$this->assertDatabaseMissing('permission_assignments', [
|
||||
'id' => $userAssignment->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('permission_assignments', [
|
||||
'id' => $teamAssignment->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('permission_assignments', [
|
||||
'id' => $teamScopeAssignment->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -66,20 +66,24 @@ class TeamTest extends TestCase
|
||||
ProjectAccessSeeder::class,
|
||||
]);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
|
||||
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||
$platformTeam = Team::query()->where('name', 'Platform Team')->firstOrFail();
|
||||
$testUser = User::query()->where('email', 'test@example.com')->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('project_user_permissions', [
|
||||
'project_id' => $coreProject->id,
|
||||
'user_id' => $admin->id,
|
||||
'permission' => 'view',
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $platformTeam->id,
|
||||
'assignee_type' => $platformTeam->getMorphClass(),
|
||||
'permission' => 'project:write',
|
||||
'scope_id' => $coreProject->id,
|
||||
'scope_type' => $coreProject->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('project_team_permissions', [
|
||||
'project_id' => $coreProject->id,
|
||||
'team_id' => $platformTeam->id,
|
||||
'permission' => 'edit',
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $testUser->id,
|
||||
'assignee_type' => $testUser->getMorphClass(),
|
||||
'permission' => 'team:manage',
|
||||
'scope_id' => $platformTeam->id,
|
||||
'scope_type' => $platformTeam->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,19 @@ class UserTest extends TestCase
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the default user factory creates non-admin users.
|
||||
* Ensure the default user factory does not grant global admin access.
|
||||
*/
|
||||
public function test_user_factory_defaults_to_non_admin(): void
|
||||
public function test_user_factory_defaults_to_no_global_admin_access(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertFalse($user->is_admin);
|
||||
$this->assertDatabaseMissing('permission_assignments', [
|
||||
'assignee_id' => $user->id,
|
||||
'assignee_type' => $user->getMorphClass(),
|
||||
'permission' => 'global:admin',
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +35,13 @@ class UserTest extends TestCase
|
||||
{
|
||||
$user = User::factory()->admin()->create();
|
||||
|
||||
$this->assertTrue($user->is_admin);
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $user->id,
|
||||
'assignee_type' => $user->getMorphClass(),
|
||||
'permission' => 'global:admin',
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,14 +51,23 @@ class UserTest extends TestCase
|
||||
{
|
||||
$this->seed(UserSeeder::class);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'admin@kanbangit.test',
|
||||
'is_admin' => true,
|
||||
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
|
||||
$member = User::query()->where('email', 'test@example.com')->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $admin->id,
|
||||
'assignee_type' => $admin->getMorphClass(),
|
||||
'permission' => 'global:admin',
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'test@example.com',
|
||||
'is_admin' => false,
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'permission' => 'project:create',
|
||||
'scope_id' => null,
|
||||
'scope_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Permissions;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PermissionServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure direct project permissions expand through the configured hierarchy.
|
||||
*/
|
||||
public function test_direct_project_permissions_expand_through_hierarchy(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$service->sync($user, $project, [Permission::ProjectWrite]);
|
||||
|
||||
$this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
|
||||
$this->assertTrue($service->allows($user, Permission::ProjectWrite, $project));
|
||||
$this->assertFalse($service->allows($user, Permission::ProjectAdmin, $project));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure project permissions assigned to teams are inherited by team members.
|
||||
*/
|
||||
public function test_team_project_permissions_are_inherited_by_members(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$team->users()->attach($user);
|
||||
$service->sync($team, $project, [Permission::ProjectRead]);
|
||||
|
||||
$this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user and team permissions are combined instead of one replacing the other.
|
||||
*/
|
||||
public function test_direct_user_permissions_are_combined_with_inherited_team_permissions(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$team->users()->attach($user);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
$service->sync($user, $project, [Permission::ProjectRead]);
|
||||
|
||||
$this->assertTrue($service->allows($user, Permission::ProjectWrite, $project));
|
||||
$this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure team-scoped permissions are granted directly to users and do not come from membership alone.
|
||||
*/
|
||||
public function test_team_permissions_only_apply_when_directly_assigned_to_the_user(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
$manager = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$team->users()->attach([$manager->id, $member->id]);
|
||||
$service->sync($manager, $team, [Permission::TeamManage]);
|
||||
|
||||
$this->assertTrue($service->allows($manager, Permission::TeamManage, $team));
|
||||
$this->assertFalse($service->allows($member, Permission::TeamManage, $team));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure global admins can perform global, project, and team-scoped actions.
|
||||
*/
|
||||
public function test_global_admin_overrides_every_scope(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
$admin = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$service->sync($admin, null, [Permission::GlobalAdmin]);
|
||||
|
||||
$this->assertTrue($service->allows($admin, Permission::ProjectCreate));
|
||||
$this->assertTrue($service->allows($admin, Permission::ProjectAdmin, $project));
|
||||
$this->assertTrue($service->allows($admin, Permission::TeamManage, $team));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the service can surface accessible project ids and manageable team ids.
|
||||
*/
|
||||
public function test_scope_lookup_helpers_return_expected_ids(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$team->users()->attach($user);
|
||||
$service->sync($team, $project, [Permission::ProjectRead]);
|
||||
$service->sync($user, $team, [Permission::TeamManage]);
|
||||
|
||||
$this->assertSame([$project->id], $service->viewableProjectIds($user));
|
||||
$this->assertSame([$team->id], $service->manageableTeamIds($user));
|
||||
$this->assertTrue($service->canManageAnyTeam($user));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
@@ -47,7 +46,7 @@ class ProjectBacklogControllerTest extends TestCase
|
||||
'title' => 'Seed backlog item',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.backlog.index', $project))
|
||||
@@ -77,7 +76,7 @@ class ProjectBacklogControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.backlog.store', $project), [
|
||||
@@ -107,7 +106,7 @@ class ProjectBacklogControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['create', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['create', 'view']);
|
||||
|
||||
$payload = [
|
||||
'title' => 'Same title',
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
@@ -63,7 +62,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'title' => 'Board ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.board.index', $project))
|
||||
@@ -113,7 +112,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'title' => 'Backlog ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||
@@ -172,7 +171,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'title' => 'Board ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||
@@ -227,7 +226,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'title' => 'Board ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||
@@ -280,7 +279,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.backlog.index', $project))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
@@ -28,7 +27,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.board', $project))
|
||||
@@ -63,7 +62,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.board.columns.store', $project), [
|
||||
@@ -99,7 +98,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
'position' => 3,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.board.columns.update', [$project, $thirdColumn]), [
|
||||
@@ -139,7 +138,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
'position' => 3,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn]))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
@@ -80,9 +79,8 @@ class ProjectControllerTest extends TestCase
|
||||
'key' => 'HIDE',
|
||||
]);
|
||||
|
||||
$projectPermissionService = app(ProjectPermissionService::class);
|
||||
$projectPermissionService->syncUserPermissions($directProject, $user, ['view']);
|
||||
$projectPermissionService->syncTeamPermissions($teamProject, $team, ['view']);
|
||||
$this->syncProjectPermissions($directProject, $user, ['view']);
|
||||
$this->syncProjectPermissions($teamProject, $team, ['view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.index'))
|
||||
@@ -96,11 +94,12 @@ class ProjectControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authenticated users can view the project creation screen.
|
||||
* Ensure users with project create permission can view the project creation screen.
|
||||
*/
|
||||
public function test_authenticated_users_can_view_the_project_creation_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.create'))
|
||||
@@ -111,11 +110,24 @@ class ProjectControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure project keys are normalized to uppercase on creation.
|
||||
* Ensure users without project create permission cannot view the project creation screen.
|
||||
*/
|
||||
public function test_users_without_project_create_permission_cannot_view_the_project_creation_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.create'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users with project create permission can create projects with uppercase keys.
|
||||
*/
|
||||
public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->post(route('projects.store'), [
|
||||
@@ -132,13 +144,13 @@ class ProjectControllerTest extends TestCase
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
foreach (ProjectPermissionService::permissions() as $permission) {
|
||||
$this->assertDatabaseHas('project_user_permissions', [
|
||||
'project_id' => $project->id,
|
||||
'user_id' => $user->id,
|
||||
'permission' => $permission,
|
||||
]);
|
||||
}
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $user->id,
|
||||
'assignee_type' => $user->getMorphClass(),
|
||||
'permission' => 'project:admin',
|
||||
'scope_id' => $project->id,
|
||||
'scope_type' => $project->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ticket_types', [
|
||||
'project_id' => $project->id,
|
||||
@@ -161,6 +173,7 @@ class ProjectControllerTest extends TestCase
|
||||
public function test_project_keys_must_be_unique_case_insensitively(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
|
||||
Project::factory()->create([
|
||||
'key' => 'AUTH',
|
||||
@@ -182,6 +195,7 @@ class ProjectControllerTest extends TestCase
|
||||
public function test_project_keys_may_not_exceed_five_characters(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.create'))
|
||||
@@ -203,7 +217,7 @@ class ProjectControllerTest extends TestCase
|
||||
'name' => 'Core Platform',
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.show', $project))
|
||||
@@ -228,7 +242,7 @@ class ProjectControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$team->users()->attach($user);
|
||||
app(ProjectPermissionService::class)->syncTeamPermissions($project, $team, ['view']);
|
||||
$this->syncProjectPermissions($project, $team, ['view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.show', $project))
|
||||
@@ -253,9 +267,9 @@ class ProjectControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure edit access allows opening the project access management page.
|
||||
* Ensure project admin access allows opening the project access management page.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_view_project_access_management(): void
|
||||
public function test_users_with_project_admin_permission_can_view_project_access_management(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
@@ -265,7 +279,7 @@ class ProjectControllerTest extends TestCase
|
||||
'name' => 'Platform Team',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.access', $project))
|
||||
@@ -275,7 +289,7 @@ class ProjectControllerTest extends TestCase
|
||||
->where('project.id', $project->id)
|
||||
->has('users', 1)
|
||||
->has('teams', 1)
|
||||
->where('permissionOptions.0.value', 'create'),
|
||||
->where('permissionOptions.0.value', 'project:read'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,7 +323,7 @@ class ProjectControllerTest extends TestCase
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']);
|
||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.general', $project))
|
||||
@@ -348,7 +362,7 @@ class ProjectControllerTest extends TestCase
|
||||
'title' => 'Delete me',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $member, ['view']);
|
||||
$this->syncProjectPermissions($project, $member, ['view']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('projects.destroy', $project), [
|
||||
@@ -366,10 +380,12 @@ class ProjectControllerTest extends TestCase
|
||||
$this->assertDatabaseMissing('tickets', [
|
||||
'id' => $ticket->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('project_user_permissions', [
|
||||
'project_id' => $project->id,
|
||||
'user_id' => $member->id,
|
||||
'permission' => 'view',
|
||||
$this->assertDatabaseMissing('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'permission' => 'project:read',
|
||||
'scope_id' => $project->id,
|
||||
'scope_type' => $project->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -406,7 +422,7 @@ class ProjectControllerTest extends TestCase
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']);
|
||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.destroy', $project), [
|
||||
@@ -434,28 +450,32 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('projects.permissions.users.update', [$project, $member]), [
|
||||
'permissions' => ['view', 'edit'],
|
||||
'permissions' => ['project:read', 'project:write'],
|
||||
])
|
||||
->assertRedirect(route('projects.access', $project))
|
||||
->assertSessionHas('status', 'Regular User permissions were updated.');
|
||||
|
||||
$this->assertDatabaseHas('project_user_permissions', [
|
||||
'project_id' => $project->id,
|
||||
'user_id' => $member->id,
|
||||
'permission' => 'view',
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'permission' => 'project:read',
|
||||
'scope_id' => $project->id,
|
||||
'scope_type' => $project->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('project_user_permissions', [
|
||||
'project_id' => $project->id,
|
||||
'user_id' => $member->id,
|
||||
'permission' => 'edit',
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'permission' => 'project:write',
|
||||
'scope_id' => $project->id,
|
||||
'scope_type' => $project->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users with project edit access can update team permissions.
|
||||
* Ensure users with project admin access can update team permissions.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_update_team_permissions(): void
|
||||
public function test_users_with_project_admin_permission_can_update_team_permissions(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$team = Team::factory()->create([
|
||||
@@ -465,25 +485,29 @@ class ProjectControllerTest extends TestCase
|
||||
'key' => 'OPS',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $manager, ['edit']);
|
||||
$this->syncProjectPermissions($project, $manager, ['admin']);
|
||||
|
||||
$this->actingAs($manager)
|
||||
->patch(route('projects.permissions.teams.update', [$project, $team]), [
|
||||
'permissions' => ['view', 'create'],
|
||||
'permissions' => ['project:read', 'project:write'],
|
||||
])
|
||||
->assertRedirect(route('projects.access', $project))
|
||||
->assertSessionHas('status', 'Delivery Team permissions were updated.');
|
||||
|
||||
$this->assertDatabaseHas('project_team_permissions', [
|
||||
'project_id' => $project->id,
|
||||
'team_id' => $team->id,
|
||||
'permission' => 'view',
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $team->id,
|
||||
'assignee_type' => $team->getMorphClass(),
|
||||
'permission' => 'project:read',
|
||||
'scope_id' => $project->id,
|
||||
'scope_type' => $project->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('project_team_permissions', [
|
||||
'project_id' => $project->id,
|
||||
'team_id' => $team->id,
|
||||
'permission' => 'create',
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $team->id,
|
||||
'assignee_type' => $team->getMorphClass(),
|
||||
'permission' => 'project:write',
|
||||
'scope_id' => $project->id,
|
||||
'scope_type' => $project->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\User;
|
||||
@@ -37,7 +36,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
|
||||
'repository_url' => 'https://git.example.test/core-team/kanbangit',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.repositories', $project))
|
||||
@@ -92,7 +91,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.repositories.store', $project), [
|
||||
@@ -126,7 +125,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
|
||||
$project = Project::factory()->create();
|
||||
$foreignGitConnection = UserGitConnection::factory()->for($otherUser)->create();
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.repositories.store', $project), [
|
||||
@@ -173,7 +172,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.repositories.update', [$project, $repositoryConnection]), [
|
||||
@@ -204,7 +203,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
|
||||
'repository_full_name' => 'core-team/kanbangit',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.repositories.destroy', [$project, $repositoryConnection]))
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
@@ -48,7 +47,7 @@ class ProjectTicketBranchControllerTest extends TestCase
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson(route('projects.tickets.branches.validate', [
|
||||
@@ -92,7 +91,7 @@ class ProjectTicketBranchControllerTest extends TestCase
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson(route('projects.tickets.branches.validate', [
|
||||
@@ -128,7 +127,7 @@ class ProjectTicketBranchControllerTest extends TestCase
|
||||
'repository_url' => 'https://git.example.test/core-team/kanbangit',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson(route('projects.tickets.branches.validate', [
|
||||
@@ -188,7 +187,7 @@ class ProjectTicketBranchControllerTest extends TestCase
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.tickets.branches.store', [$project, $ticket]), [
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
@@ -86,7 +85,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'ticket_id' => $ticket->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
|
||||
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get(route('projects.tickets.show', [$project, $ticket]))
|
||||
@@ -196,7 +195,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'base_url' => 'https://git.example.test',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
|
||||
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get(route('projects.tickets.show', [$project, $ticket]))
|
||||
@@ -244,7 +243,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
|
||||
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
@@ -325,7 +324,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'value_user_id' => null,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
@@ -417,7 +416,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'title' => 'Child ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->from(route('projects.tickets.show', [$project, $childTicket]))
|
||||
@@ -474,7 +473,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'title' => 'Child ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch(route('projects.tickets.update', [$project, $childTicket]), [
|
||||
@@ -563,7 +562,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'title' => 'Nested task',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
|
||||
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get(route('projects.tickets.show', [$project, $rootParent]))
|
||||
@@ -596,7 +595,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->from(route('projects.tickets.show', [$project, $ticket]))
|
||||
@@ -645,7 +644,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
@@ -53,7 +52,7 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
'ticket_type_id' => $taskType->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
||||
@@ -118,7 +117,7 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
'ticket_type_id' => $otherTaskType->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.tickets.show', [$project, $ticket]))
|
||||
@@ -180,7 +179,7 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink]))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
@@ -33,7 +32,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.metadata', $project))
|
||||
@@ -55,7 +54,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.metadata.types.store', $project), [
|
||||
@@ -80,7 +79,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$taskType = TicketType::query()
|
||||
->where('project_id', $project->id)
|
||||
@@ -130,7 +129,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.metadata.priorities.store', $project), [
|
||||
@@ -169,7 +168,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.metadata.fields.store', $project), [
|
||||
@@ -203,7 +202,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.settings.metadata', $project))
|
||||
@@ -227,7 +226,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.metadata.link-types.store', $project), [
|
||||
|
||||
@@ -28,10 +28,11 @@ class TeamControllerTest extends TestCase
|
||||
$team->users()->attach($member);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.teams.index'))
|
||||
->get(route('teams.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('teams/Index')
|
||||
->where('canCreateTeams', true)
|
||||
->where('teams.0.name', 'Platform Team')
|
||||
->where('teams.0.user_ids.0', $member->id)
|
||||
->where('teams.0.users.0.email', 'member@example.com'),
|
||||
@@ -46,10 +47,10 @@ class TeamControllerTest extends TestCase
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.teams.store'), [
|
||||
->post(route('teams.store'), [
|
||||
'name' => 'Delivery Team',
|
||||
])
|
||||
->assertRedirect(route('admin.teams.index'))
|
||||
->assertRedirect(route('teams.index'))
|
||||
->assertSessionHas('status', 'Delivery Team was created.');
|
||||
|
||||
$this->assertDatabaseHas('teams', [
|
||||
@@ -70,10 +71,10 @@ class TeamControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.teams.members.update', $team), [
|
||||
->patch(route('teams.members.update', $team), [
|
||||
'user_ids' => [$firstMember->id, $secondMember->id],
|
||||
])
|
||||
->assertRedirect(route('admin.teams.index'))
|
||||
->assertRedirect(route('teams.index'))
|
||||
->assertSessionHas('status', 'Platform Team members were updated.');
|
||||
|
||||
$this->assertDatabaseHas('team_user', [
|
||||
@@ -88,14 +89,61 @@ class TeamControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure non-admin users cannot view the teams management page.
|
||||
* Ensure admins can view the teams management page.
|
||||
*/
|
||||
public function test_non_admins_with_team_create_permission_can_view_the_teams_management_page(): void
|
||||
{
|
||||
$createTeamUser = User::factory()->create();
|
||||
$member = User::factory()->create([
|
||||
'name' => 'Regular User',
|
||||
'email' => 'member@example.com',
|
||||
]);
|
||||
$team = Team::factory()->create([
|
||||
'name' => 'Platform Team',
|
||||
]);
|
||||
$team->users()->attach($member);
|
||||
|
||||
$this->syncGlobalPermissions($createTeamUser, ['team:create']);
|
||||
|
||||
$this->actingAs($createTeamUser)
|
||||
->get(route('teams.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('teams/Index')
|
||||
->where('canCreateTeams', true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can create teams.
|
||||
*/
|
||||
public function test_non_admins_with_team_create_permission_can_create_teams(): void
|
||||
{
|
||||
$createTeamUser = User::factory()->create();
|
||||
|
||||
$this->syncGlobalPermissions($createTeamUser, ['team:create']);
|
||||
|
||||
$this->actingAs($createTeamUser)
|
||||
->post(route('teams.store'), [
|
||||
'name' => 'Delivery Team',
|
||||
])
|
||||
->assertRedirect(route('teams.index'))
|
||||
->assertSessionHas('status', 'Delivery Team was created.');
|
||||
|
||||
$this->assertDatabaseHas('teams', [
|
||||
'name' => 'Delivery Team',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users without team management access cannot view the teams page.
|
||||
*/
|
||||
public function test_non_admins_cannot_view_the_teams_management_page(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.teams.index'))
|
||||
->get(route('teams.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
@@ -107,7 +155,7 @@ class TeamControllerTest extends TestCase
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.teams.store'), [
|
||||
->post(route('teams.store'), [
|
||||
'name' => 'Platform Team',
|
||||
])
|
||||
->assertForbidden();
|
||||
@@ -122,9 +170,48 @@ class TeamControllerTest extends TestCase
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('admin.teams.members.update', $team), [
|
||||
->patch(route('teams.members.update', $team), [
|
||||
'user_ids' => [],
|
||||
])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users with team manage access can view and update their assigned teams.
|
||||
*/
|
||||
public function test_team_managers_can_view_and_update_their_team(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$team = Team::factory()->create([
|
||||
'name' => 'Platform Team',
|
||||
]);
|
||||
|
||||
$this->syncTeamPermissions($team, $manager, ['team:manage']);
|
||||
|
||||
$this->actingAs($manager)
|
||||
->get(route('teams.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('teams/Index')
|
||||
->where('canCreateTeams', false)
|
||||
->where('teams.0.name', 'Platform Team')
|
||||
->where("teamPermissions.{$team->id}.{$manager->id}.0", 'team:manage'),
|
||||
);
|
||||
|
||||
$this->actingAs($manager)
|
||||
->patch(route('teams.permissions.users.update', [$team, $member]), [
|
||||
'permissions' => ['team:invite'],
|
||||
])
|
||||
->assertRedirect(route('teams.index'))
|
||||
->assertSessionHas('status', "{$member->name} permissions on {$team->name} were updated.");
|
||||
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
'assignee_id' => $member->id,
|
||||
'assignee_type' => $member->getMorphClass(),
|
||||
'permission' => 'team:invite',
|
||||
'scope_id' => $team->id,
|
||||
'scope_type' => $team->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
@@ -13,4 +18,79 @@ abstract class TestCase extends BaseTestCase
|
||||
$this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Permission|string> $permissions
|
||||
*/
|
||||
protected function syncGlobalPermissions(User $user, array $permissions): void
|
||||
{
|
||||
app(PermissionService::class)->sync(
|
||||
$user,
|
||||
null,
|
||||
$this->normalizePermissions($permissions, 'global'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Permission|string> $permissions
|
||||
*/
|
||||
protected function syncProjectPermissions(Project $project, User|Team $assignee, array $permissions): void
|
||||
{
|
||||
app(PermissionService::class)->sync(
|
||||
$assignee,
|
||||
$project,
|
||||
$this->normalizePermissions($permissions, 'project'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Permission|string> $permissions
|
||||
*/
|
||||
protected function syncTeamPermissions(Team $team, User $user, array $permissions): void
|
||||
{
|
||||
app(PermissionService::class)->sync(
|
||||
$user,
|
||||
$team,
|
||||
$this->normalizePermissions($permissions, 'team'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Permission|string> $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizePermissions(array $permissions, string $scope): array
|
||||
{
|
||||
return collect($permissions)
|
||||
->map(function (Permission|string $permission) use ($scope): string {
|
||||
if ($permission instanceof Permission) {
|
||||
return $permission->value;
|
||||
}
|
||||
|
||||
return match ($scope) {
|
||||
'global' => match ($permission) {
|
||||
'admin', 'global:admin' => Permission::GlobalAdmin->value,
|
||||
'create', 'project:create' => Permission::ProjectCreate->value,
|
||||
default => $permission,
|
||||
},
|
||||
'project' => match ($permission) {
|
||||
'admin', 'delete', 'project:admin' => Permission::ProjectAdmin->value,
|
||||
'edit' => Permission::ProjectAdmin->value,
|
||||
'create', 'project:write', 'write' => Permission::ProjectWrite->value,
|
||||
'project:read', 'read', 'view' => Permission::ProjectRead->value,
|
||||
default => $permission,
|
||||
},
|
||||
'team' => match ($permission) {
|
||||
'admin', 'team:admin' => Permission::TeamAdmin->value,
|
||||
'invite', 'team:invite' => Permission::TeamInvite->value,
|
||||
'manage', 'team:manage' => Permission::TeamManage->value,
|
||||
default => $permission,
|
||||
},
|
||||
default => $permission,
|
||||
};
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user