permissions refactoring

This commit is contained in:
2026-04-12 19:21:04 +02:00
parent 51014395a2
commit 9dcdfb981a
64 changed files with 1842 additions and 656 deletions
@@ -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();
}
}
+175
View File
@@ -0,0 +1,175 @@
<?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 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::TeamInvite => 'Team invite',
self::TeamManage => 'Team manage',
self::TeamAdmin => 'Team admin',
};
}
public function scope(): PermissionScope
{
return match ($this) {
self::GlobalAdmin, self::ProjectCreate => 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;
}
foreach ($this->impliedPermissions() as $impliedPermission) {
if ($impliedPermission->includes($permission)) {
return true;
}
}
return false;
}
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::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,
);
}
}
+10
View File
@@ -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; namespace App\Http\Controllers\Admin;
use App\Actions\Permissions\PermissionService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateUserAdminStatusRequest; use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -12,28 +13,30 @@ use Inertia\Response;
class UserManagementController extends Controller class UserManagementController extends Controller
{ {
public function __construct(
private readonly PermissionService $permissionService,
) {}
public function index(): Response public function index(): Response
{ {
return Inertia::render('admin/users/Index', [ return Inertia::render('admin/users/Index', [
'directPermissions' => $this->permissionService->assignmentMap(User::class),
'permissionOptions' => $this->permissionService->permissionOptions(User::class, null),
'status' => session('status'), 'status' => session('status'),
'users' => User::query() 'users' => User::query()
->orderByDesc('is_admin')
->orderBy('name') ->orderBy('name')
->get(), ->get(),
]); ]);
} }
public function update(UpdateUserAdminStatusRequest $request, User $user): RedirectResponse public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse
{ {
$validated = $request->validated(); $validated = $request->validated();
$user->forceFill([ $permissions = Arr::get($validated, 'permissions', []);
'is_admin' => Arr::get($validated, 'is_admin'),
])->save();
$status = $user->is_admin $this->permissionService->sync($user, null, $permissions);
? "{$user->name} now has admin access."
: "{$user->name} no longer has admin access.";
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; namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket; use App\Actions\Tickets\CreateProjectTicket;
use App\Enums\Permission;
use App\Http\Requests\StoreProjectTicketRequest; use App\Http\Requests\StoreProjectTicketRequest;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -16,7 +17,7 @@ class ProjectBacklogController extends Controller
{ {
public function __construct( public function __construct(
private readonly CreateProjectTicket $createProjectTicket, private readonly CreateProjectTicket $createProjectTicket,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function index(Request $request, Project $project): Response public function index(Request $request, Project $project): Response
@@ -24,14 +25,14 @@ class ProjectBacklogController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project), $this->permissionService->allows($user, Permission::ProjectRead, $project),
403, 403,
); );
return Inertia::render('projects/Backlog', [ return Inertia::render('projects/Backlog', [
'boardColumns' => $project->boardColumns()->get(), 'boardColumns' => $project->boardColumns()->get(),
'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'canCreateTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'create'), 'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'project' => $project, 'project' => $project,
'status' => session('status'), 'status' => session('status'),
'ticketPriorities' => $project->ticketPriorities()->get(), 'ticketPriorities' => $project->ticketPriorities()->get(),
@@ -51,7 +52,7 @@ class ProjectBacklogController extends Controller
$validated = $request->validated(); $validated = $request->validated();
abort_unless( abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'create'), $this->permissionService->allows($user, Permission::ProjectWrite, $project),
403, 403,
); );
@@ -2,8 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateTicketWorklogEntry; use App\Actions\Tickets\CreateTicketWorklogEntry;
use App\Enums\Permission;
use App\Http\Requests\MoveProjectTicketRequest; use App\Http\Requests\MoveProjectTicketRequest;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -17,7 +18,7 @@ class ProjectBoardController extends Controller
{ {
public function __construct( public function __construct(
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function index(Request $request, Project $project): Response public function index(Request $request, Project $project): Response
@@ -25,7 +26,7 @@ class ProjectBoardController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project), $this->permissionService->allows($user, Permission::ProjectRead, $project),
403, 403,
); );
@@ -36,7 +37,7 @@ class ProjectBoardController extends Controller
->with(['ticketPriority', 'ticketType']), ->with(['ticketPriority', 'ticketType']),
]) ])
->get(), ->get(),
'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'project' => $project, 'project' => $project,
'status' => session('status'), 'status' => session('status'),
]); ]);
@@ -50,7 +51,7 @@ class ProjectBoardController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'), $this->permissionService->allows($user, Permission::ProjectWrite, $project),
403, 403,
); );
@@ -2,7 +2,8 @@
namespace App\Http\Controllers; 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\StoreBoardColumnRequest;
use App\Http\Requests\UpdateBoardColumnRequest; use App\Http\Requests\UpdateBoardColumnRequest;
use App\Models\BoardColumn; use App\Models\BoardColumn;
@@ -18,13 +19,13 @@ use Inertia\Response;
class ProjectBoardSettingsController extends Controller class ProjectBoardSettingsController extends Controller
{ {
public function __construct( public function __construct(
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function show(Request $request, Project $project): Response public function show(Request $request, Project $project): Response
{ {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
@@ -40,7 +41,7 @@ class ProjectBoardSettingsController extends Controller
Project $project, Project $project,
): RedirectResponse { ): RedirectResponse {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
$validated = $request->validated(); $validated = $request->validated();
@@ -68,7 +69,7 @@ class ProjectBoardSettingsController extends Controller
BoardColumn $boardColumn, BoardColumn $boardColumn,
): RedirectResponse { ): RedirectResponse {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
$validated = $request->validated(); $validated = $request->validated();
@@ -91,7 +92,7 @@ class ProjectBoardSettingsController extends Controller
BoardColumn $boardColumn, BoardColumn $boardColumn,
): RedirectResponse { ): RedirectResponse {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
+23 -16
View File
@@ -2,8 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Enums\Permission;
use App\Http\Requests\DeleteProjectRequest; use App\Http\Requests\DeleteProjectRequest;
use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SyncProjectPermissionsRequest; use App\Http\Requests\SyncProjectPermissionsRequest;
@@ -20,7 +21,7 @@ class ProjectController extends Controller
{ {
public function __construct( public function __construct(
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function index(Request $request): Response public function index(Request $request): Response
@@ -30,12 +31,13 @@ class ProjectController extends Controller
$projectQuery = Project::query() $projectQuery = Project::query()
->orderByDesc('id'); ->orderByDesc('id');
if (! $user->is_admin) { if (! $this->permissionService->allows($user, Permission::GlobalAdmin)) {
$viewableProjectIds = $this->projectPermissionService->viewableProjectIds($user); $viewableProjectIds = $this->permissionService->viewableProjectIds($user);
if ($viewableProjectIds === []) { if ($viewableProjectIds === []) {
return Inertia::render('projects/Index', [ return Inertia::render('projects/Index', [
'projects' => [], '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'); return Inertia::render('projects/Create');
} }
@@ -58,7 +65,7 @@ class ProjectController extends Controller
{ {
$project = Project::query()->create($request->validated()); $project = Project::query()->create($request->validated());
$this->initializeProjectTicketMetadata->handle($project); $this->initializeProjectTicketMetadata->handle($project);
$this->projectPermissionService->grantAllProjectPermissions($project, $request->user()); $this->permissionService->grant($request->user(), Permission::ProjectAdmin, $project);
return to_route('projects.show', $project); return to_route('projects.show', $project);
} }
@@ -68,12 +75,12 @@ class ProjectController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project), $this->permissionService->allows($user, Permission::ProjectRead, $project),
403, 403,
); );
return Inertia::render('projects/Show', [ return Inertia::render('projects/Show', [
'canManageAccess' => $this->projectPermissionService->userCanManageProjectAccess($user, $project), 'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => $project, 'project' => $project,
]); ]);
} }
@@ -90,16 +97,16 @@ class ProjectController extends Controller
{ {
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project), $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403, 403,
); );
return Inertia::render('projects/Access', [ return Inertia::render('projects/Access', [
'directPermissions' => $this->projectPermissionService->userPermissionMap($project), 'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
'permissionOptions' => $this->projectPermissionService->permissionOptions(), 'permissionOptions' => $this->permissionService->permissionOptions(User::class, Project::class),
'project' => $project, 'project' => $project,
'status' => session('status'), 'status' => session('status'),
'teamPermissions' => $this->projectPermissionService->teamPermissionMap($project), 'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => Team::query() 'teams' => Team::query()
->with('users') ->with('users')
->orderBy('name') ->orderBy('name')
@@ -125,14 +132,14 @@ class ProjectController extends Controller
): RedirectResponse { ): RedirectResponse {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
$validated = $request->validated(); $validated = $request->validated();
$permissions = Arr::get($validated, 'permissions', []); $permissions = Arr::get($validated, 'permissions', []);
$this->projectPermissionService->syncUserPermissions($project, $user, $permissions); $this->permissionService->sync($user, $project, $permissions);
return to_route('projects.access', $project) return to_route('projects.access', $project)
->with('status', "{$user->name} permissions were updated."); ->with('status', "{$user->name} permissions were updated.");
@@ -144,14 +151,14 @@ class ProjectController extends Controller
Team $team, Team $team,
): RedirectResponse { ): RedirectResponse {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
$validated = $request->validated(); $validated = $request->validated();
$permissions = Arr::get($validated, 'permissions', []); $permissions = Arr::get($validated, 'permissions', []);
$this->projectPermissionService->syncTeamPermissions($project, $team, $permissions); $this->permissionService->sync($team, $project, $permissions);
return to_route('projects.access', $project) return to_route('projects.access', $project)
->with('status', "{$team->name} permissions were updated."); ->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\Data\GitRepository;
use App\Actions\Git\GitProviderManager; 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\StoreRepositoryConnectionRequest;
use App\Http\Requests\UpdateRepositoryConnectionRequest; use App\Http\Requests\UpdateRepositoryConnectionRequest;
use App\Models\Project; use App\Models\Project;
@@ -24,14 +25,14 @@ class ProjectRepositorySettingsController extends Controller
{ {
public function __construct( public function __construct(
private readonly GitProviderManager $gitProviderManager, private readonly GitProviderManager $gitProviderManager,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function show(Request $request, Project $project): Response public function show(Request $request, Project $project): Response
{ {
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project), $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403, 403,
); );
@@ -66,7 +67,7 @@ class ProjectRepositorySettingsController extends Controller
): RedirectResponse { ): RedirectResponse {
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project), $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403, 403,
); );
@@ -96,7 +97,7 @@ class ProjectRepositorySettingsController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project), $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403, 403,
); );
@@ -128,7 +129,7 @@ class ProjectRepositorySettingsController extends Controller
RepositoryConnection $repositoryConnection, RepositoryConnection $repositoryConnection,
): RedirectResponse { ): RedirectResponse {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
@@ -5,7 +5,8 @@ namespace App\Http\Controllers;
use App\Actions\Git\Data\GitBranch; use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\GitProviderManager; use App\Actions\Git\GitProviderManager;
use App\Actions\Git\ValidateGitBranchName; 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\StoreProjectTicketBranchRequest;
use App\Http\Requests\ValidateProjectTicketBranchRequest; use App\Http\Requests\ValidateProjectTicketBranchRequest;
use App\Models\Project; use App\Models\Project;
@@ -23,7 +24,7 @@ class ProjectTicketBranchController extends Controller
public function __construct( public function __construct(
private readonly GitProviderManager $gitProviderManager, private readonly GitProviderManager $gitProviderManager,
private readonly ValidateGitBranchName $validateGitBranchName, private readonly ValidateGitBranchName $validateGitBranchName,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function validate( public function validate(
@@ -34,7 +35,7 @@ class ProjectTicketBranchController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'), $this->permissionService->allows($user, Permission::ProjectWrite, $project),
403, 403,
); );
@@ -110,7 +111,7 @@ class ProjectTicketBranchController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'), $this->permissionService->allows($user, Permission::ProjectWrite, $project),
403, 403,
); );
@@ -5,8 +5,9 @@ namespace App\Http\Controllers;
use App\Actions\Git\BuildSuggestedTicketBranchName; use App\Actions\Git\BuildSuggestedTicketBranchName;
use App\Actions\Git\ResolveTicketBranchMatches; use App\Actions\Git\ResolveTicketBranchMatches;
use App\Actions\Git\ResolveTicketPullRequestMatches; use App\Actions\Git\ResolveTicketPullRequestMatches;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\UpdateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket;
use App\Enums\Permission;
use App\Http\Requests\UpdateProjectTicketRequest; use App\Http\Requests\UpdateProjectTicketRequest;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -22,7 +23,7 @@ class ProjectTicketController extends Controller
{ {
public function __construct( public function __construct(
private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName, private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches, private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches, private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
private readonly UpdateProjectTicket $updateProjectTicket, private readonly UpdateProjectTicket $updateProjectTicket,
@@ -33,14 +34,14 @@ class ProjectTicketController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project), $this->permissionService->allows($user, Permission::ProjectRead, $project),
403, 403,
); );
$excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)]; $excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)];
return Inertia::render('projects/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), 'childTicketTree' => $this->buildChildTicketTree($ticket),
'linkedTickets' => $this->linkedTickets($ticket), 'linkedTickets' => $this->linkedTickets($ticket),
'parentTicketOptions' => Ticket::query() 'parentTicketOptions' => Ticket::query()
@@ -103,7 +104,7 @@ class ProjectTicketController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'), $this->permissionService->allows($user, Permission::ProjectWrite, $project),
403, 403,
); );
@@ -2,8 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateTicketWorklogEntry; use App\Actions\Tickets\CreateTicketWorklogEntry;
use App\Enums\Permission;
use App\Http\Requests\StoreTicketLinkRequest; use App\Http\Requests\StoreTicketLinkRequest;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -17,7 +18,7 @@ class ProjectTicketLinkController extends Controller
{ {
public function __construct( public function __construct(
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function store( public function store(
@@ -28,7 +29,7 @@ class ProjectTicketLinkController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'), $this->permissionService->allows($user, Permission::ProjectWrite, $project),
403, 403,
); );
@@ -93,7 +94,7 @@ class ProjectTicketLinkController extends Controller
$user = $request->user(); $user = $request->user();
abort_unless( abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'), $this->permissionService->allows($user, Permission::ProjectWrite, $project),
403, 403,
); );
@@ -2,8 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Enums\Permission;
use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest;
@@ -26,7 +27,7 @@ class ProjectTicketMetadataSettingsController extends Controller
{ {
public function __construct( public function __construct(
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly ProjectPermissionService $projectPermissionService, private readonly PermissionService $permissionService,
) {} ) {}
public function show(Request $request, Project $project): Response public function show(Request $request, Project $project): Response
@@ -355,7 +356,7 @@ class ProjectTicketMetadataSettingsController extends Controller
private function abortUnlessCanManage(Request $request, Project $project): void private function abortUnlessCanManage(Request $request, Project $project): void
{ {
abort_unless( abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403, 403,
); );
} }
+56 -11
View File
@@ -2,12 +2,15 @@
namespace App\Http\Controllers; 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\StoreTeamRequest;
use App\Http\Requests\SyncTeamUserPermissionsRequest;
use App\Http\Requests\UpdateTeamMembersRequest; use App\Http\Requests\UpdateTeamMembersRequest;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -15,17 +18,36 @@ use Inertia\Response;
class TeamController extends Controller class TeamController extends Controller
{ {
public function __construct( 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::GlobalAdmin);
$manageableTeamIds = $this->permissionService->manageableTeamIds($user);
abort_unless($canCreateTeams || $manageableTeamIds !== [], 403);
$teams = Team::query()
->with('users')
->when(
! $canCreateTeams,
fn ($query) => $query->whereIn('id', $manageableTeamIds),
)
->orderBy('name')
->get();
return Inertia::render('teams/Index', [ return Inertia::render('teams/Index', [
'canCreateTeams' => $canCreateTeams,
'permissionOptions' => $this->permissionService->permissionOptions(User::class, Team::class),
'status' => session('status'), 'status' => session('status'),
'teams' => Team::query() 'teamPermissions' => $teams
->with('users') ->mapWithKeys(fn (Team $team): array => [
->orderBy('name') $team->id => $this->permissionService->assignmentMap(User::class, $team),
->get() ])
->all(),
'teams' => $teams
->map(fn (Team $team): array => [ ->map(fn (Team $team): array => [
'id' => $team->id, 'id' => $team->id,
'name' => $team->name, 'name' => $team->name,
@@ -44,19 +66,42 @@ class TeamController extends Controller
{ {
$team = Team::query()->create($request->validated()); $team = Team::query()->create($request->validated());
return to_route('admin.teams.index') return to_route('teams.index')
->with('status', "{$team->name} was created."); ->with('status', "{$team->name} was created.");
} }
public function updateMembers(UpdateTeamMembersRequest $request, Team $team): RedirectResponse 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', []); $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."); ->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.");
}
} }
+14 -1
View File
@@ -2,11 +2,17 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
class HandleInertiaRequests extends Middleware class HandleInertiaRequests extends Middleware
{ {
public function __construct(
private readonly PermissionService $permissionService,
) {}
/** /**
* The root template that's loaded on the first page visit. * 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 public function share(Request $request): array
{ {
$user = $request->user();
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'auth' => [ 'auth' => [
'user' => $request->user(), 'permissions' => [
'canManageTeams' => $user !== null && $this->permissionService->canManageAnyTeam($user),
'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', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
]; ];
@@ -2,10 +2,12 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use App\Enums\Permission;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; 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. * Determine if the user is authorized to make this request.
@@ -23,7 +25,12 @@ class UpdateUserAdminStatusRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'is_admin' => ['required', 'boolean'], 'permissions' => ['nullable', 'array'],
'permissions.*' => [
'string',
'distinct',
Rule::in(Permission::values(Permission::globalAssignable())),
],
]; ];
} }
} }
+1 -1
View File
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user() !== null; return $this->user()?->can('access-admin') ?? false;
} }
/** /**
+1 -1
View File
@@ -14,7 +14,7 @@ class StoreProjectRequest extends FormRequest
*/ */
public function authorize(): bool public function authorize(): bool
{ {
return $this->user() !== null; return $this->user()?->can('create-project') ?? false;
} }
protected function prepareForValidation(): void protected function prepareForValidation(): void
@@ -2,7 +2,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Enums\Permission;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -29,7 +29,7 @@ class SyncProjectPermissionsRequest extends FormRequest
'permissions.*' => [ 'permissions.*' => [
'string', 'string',
'distinct', '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 public function authorize(): bool
{ {
return $this->user()?->can('access-admin') ?? false; return $this->user() !== null;
} }
/** /**
+37
View File
@@ -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();
}
}
+13
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable(['name', 'key'])] #[Fillable(['name', 'key'])]
@@ -16,6 +17,13 @@ class Project extends Model
/** @use HasFactory<ProjectFactory> */ /** @use HasFactory<ProjectFactory> */
use HasFactory; use HasFactory;
protected static function booted(): void
{
static::deleting(function (self $project): void {
$project->permissionAssignments()->delete();
});
}
protected function key(): Attribute protected function key(): Attribute
{ {
return Attribute::make( 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 public function boardColumns(): HasMany
{ {
return $this->hasMany(BoardColumn::class) return $this->hasMany(BoardColumn::class)
+19
View File
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
#[Fillable(['name'])] #[Fillable(['name'])]
class Team extends Model class Team extends Model
@@ -14,6 +15,24 @@ class Team extends Model
/** @use HasFactory<TeamFactory> */ /** @use HasFactory<TeamFactory> */
use HasFactory; use HasFactory;
protected static function booted(): void
{
static::deleting(function (self $team): void {
$team->permissionAssignments()->delete();
$team->scopedPermissionAssignments()->delete();
});
}
public function permissionAssignments(): MorphMany
{
return $this->morphMany(PermissionAssignment::class, 'assignee');
}
public function scopedPermissionAssignments(): MorphMany
{
return $this->morphMany(PermissionAssignment::class, 'scope');
}
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
return $this->belongsToMany(User::class) return $this->belongsToMany(User::class)
+13 -1
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
@@ -20,6 +21,13 @@ class User extends Authenticatable
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable; 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. * Get the attributes that should be cast.
* *
@@ -29,12 +37,16 @@ class User extends Authenticatable
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
'password' => 'hashed', 'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime', 'two_factor_confirmed_at' => 'datetime',
]; ];
} }
public function permissionAssignments(): MorphMany
{
return $this->morphMany(PermissionAssignment::class, 'assignee');
}
public function teams(): BelongsToMany public function teams(): BelongsToMany
{ {
return $this->belongsToMany(Team::class) return $this->belongsToMany(Team::class)
+17 -1
View File
@@ -2,8 +2,13 @@
namespace App\Providers; 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 App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -40,7 +45,18 @@ class AppServiceProvider extends ServiceProvider
*/ */
protected function configureAuthorization(): void 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('manage-teams', fn (User $user): bool => $permissionService->canManageAnyTeam($user));
} }
/** /**
@@ -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);
}
}
+7 -4
View File
@@ -2,6 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\PermissionAssignment;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -28,7 +29,6 @@ class UserFactory extends Factory
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),
'is_admin' => false,
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'two_factor_secret' => null, 'two_factor_secret' => null,
@@ -64,8 +64,11 @@ class UserFactory extends Factory
*/ */
public function admin(): static public function admin(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->afterCreating(function (User $user): void {
'is_admin' => true, 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');
}
};
+25 -19
View File
@@ -2,7 +2,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Enums\Permission;
use App\Models\PermissionAssignment;
use App\Models\Project; use App\Models\Project;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
@@ -15,28 +16,33 @@ class ProjectAccessSeeder extends Seeder
*/ */
public function run(): void 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(); $coreProject = Project::query()->where('key', 'CORE')->first();
$deliveryTeam = Team::query()->where('name', 'Delivery Team')->first();
$platformTeam = Team::query()->where('name', 'Platform Team')->first(); $platformTeam = Team::query()->where('name', 'Platform Team')->first();
$testUser = User::query()->where('email', 'test@example.com')->first();
if ($coreProject !== null && $platformTeam !== null) { if ($coreProject !== null && $platformTeam !== null) {
$projectPermissionService->syncTeamPermissions($coreProject, $platformTeam, [ PermissionAssignment::factory()
'create', ->forAssignee($platformTeam)
'edit', ->forScope($coreProject)
'view', ->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();
} }
} }
} }
+15 -2
View File
@@ -2,6 +2,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\Permission;
use App\Models\PermissionAssignment;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -12,16 +14,27 @@ class UserSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
User::factory()->admin()->create([ $admin = User::factory()->create([
'name' => 'Admin User', 'name' => 'Admin User',
'email' => 'admin@kanbangit.test', 'email' => 'admin@kanbangit.test',
]); ]);
User::factory()->create([ PermissionAssignment::factory()
->forAssignee($admin)
->globalAdmin()
->create();
$testUser = User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com', 'email' => 'test@example.com',
]); ]);
PermissionAssignment::factory()
->forAssignee($testUser)
->global()
->permission(Permission::ProjectCreate)
->create();
User::factory(8)->create(); User::factory(8)->create();
} }
} }
+5 -3
View File
@@ -23,9 +23,9 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as adminTeams } from '@/routes/admin/teams';
import { index as adminUsers } from '@/routes/admin/users'; import { index as adminUsers } from '@/routes/admin/users';
import { index as projectsIndex } from '@/routes/projects'; import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const page = usePage(); 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({ items.push({
title: 'Users', title: 'Users',
href: adminUsers(), href: adminUsers(),
icon: Shield, icon: Shield,
}); });
}
if (page.props.auth.permissions.canManageTeams) {
items.push({ items.push({
title: 'Teams', title: 'Teams',
href: adminTeams(), href: teamsIndex(),
icon: Users, icon: Users,
}); });
} }
+76 -44
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; 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 UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge'; 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 { index as adminUsers } from '@/routes/admin/users';
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types'; import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
defineProps<AdminUsersPageProps>(); const props = defineProps<AdminUsersPageProps>();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
@@ -24,6 +24,9 @@ const breadcrumbs: BreadcrumbItem[] = [
href: adminUsers(), href: adminUsers(),
}, },
]; ];
const hasPermission = (actorId: number, permission: string) =>
(props.directPermissions[actorId] ?? []).includes(permission);
</script> </script>
<template> <template>
@@ -34,7 +37,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<Heading <Heading
title="User management" 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"> <Badge variant="outline" class="gap-1.5 px-3 py-1">
@@ -54,8 +57,8 @@ const breadcrumbs: BreadcrumbItem[] = [
<CardHeader class="border-b"> <CardHeader class="border-b">
<CardTitle>Users</CardTitle> <CardTitle>Users</CardTitle>
<CardDescription> <CardDescription>
Promote trusted users to platform admins or remove admin Global permissions are assigned directly to users and
access when it is no longer needed. apply across the entire application.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -68,16 +71,16 @@ const breadcrumbs: BreadcrumbItem[] = [
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-x-auto">
<table <table class="divide-border min-w-full divide-y text-sm">
class="divide-border min-w-full divide-y text-sm"
>
<thead <thead
class="bg-muted/40 text-muted-foreground text-left" class="bg-muted/40 text-muted-foreground text-left"
> >
<tr> <tr>
<th class="px-4 py-3 font-medium">User</th> <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">Email</th>
<th class="px-4 py-3 font-medium">Role</th> <th class="px-4 py-3 font-medium">
Current access
</th>
<th <th
class="px-4 py-3 text-right font-medium" class="px-4 py-3 text-right font-medium"
> >
@@ -89,12 +92,10 @@ const breadcrumbs: BreadcrumbItem[] = [
<tr <tr
v-for="user in users" v-for="user in users"
:key="user.id" :key="user.id"
class="bg-background" class="bg-background align-top"
> >
<td class="px-4 py-4"> <td class="px-4 py-4">
<div <div class="text-foreground font-medium">
class="text-foreground font-medium"
>
{{ user.name }} {{ user.name }}
</div> </div>
</td> </td>
@@ -102,19 +103,43 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ user.email }} {{ user.email }}
</td> </td>
<td class="px-4 py-4"> <td class="px-4 py-4">
<Badge <div class="flex flex-wrap gap-2">
:variant=" <Badge
user.is_admin v-if="
? 'default' hasPermission(
: 'secondary' user.id,
" 'global:admin',
class="gap-1.5" )
> "
<Shield class="size-3.5" /> class="gap-1.5"
{{ >
user.is_admin ? 'Admin' : 'User' <Shield class="size-3.5" />
}} Global admin
</Badge> </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>
<td class="px-4 py-4"> <td class="px-4 py-4">
<div class="flex justify-end"> <div class="flex justify-end">
@@ -124,33 +149,40 @@ const breadcrumbs: BreadcrumbItem[] = [
user.id, user.id,
) )
" "
class="flex flex-wrap justify-end gap-3"
v-slot="{ processing }" v-slot="{ processing }"
> >
<input <label
type="hidden" v-for="option in permissionOptions"
name="is_admin" :key="
:value=" `${user.id}-${option.value}`
user.is_admin
? '0'
: '1'
" "
/> 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 <Button
type="submit" type="submit"
size="sm" size="sm"
:variant=" variant="outline"
user.is_admin
? 'outline'
: 'default'
"
:disabled="processing" :disabled="processing"
> >
{{ Save
user.is_admin
? 'Remove admin'
: 'Make admin'
}}
</Button> </Button>
</Form> </Form>
</div> </div>
+1 -1
View File
@@ -60,7 +60,7 @@ const hasPermission = (
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Button <Button
v-if="$page.props.auth.user.is_admin" v-if="$page.props.auth.permissions.globalAdmin"
as-child as-child
variant="outline" variant="outline"
> >
+1 -1
View File
@@ -38,7 +38,7 @@ const breadcrumbs: BreadcrumbItem[] = [
description="Create and browse project spaces that will later host boards, backlog, and settings." 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()"> <Link :href="create()">
<Plus class="size-4" /> <Plus class="size-4" />
New project New project
+1 -1
View File
@@ -107,7 +107,7 @@ const projectAreas = [
</Button> </Button>
<Button <Button
v-if="$page.props.auth.user.is_admin" v-if="$page.props.auth.permissions.globalAdmin"
as-child as-child
variant="outline" variant="outline"
> >
@@ -61,7 +61,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Button <Button
v-if="$page.props.auth.user.is_admin" v-if="$page.props.auth.permissions.globalAdmin"
as-child as-child
variant="outline" variant="outline"
> >
@@ -65,7 +65,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Button <Button
v-if="$page.props.auth.user.is_admin" v-if="$page.props.auth.permissions.globalAdmin"
as-child as-child
variant="outline" variant="outline"
> >
@@ -234,7 +234,7 @@ const submitUpdate = (): void => {
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Button <Button
v-if="$page.props.auth.user.is_admin" v-if="$page.props.auth.permissions.globalAdmin"
as-child as-child
variant="outline" variant="outline"
> >
+193 -8
View File
@@ -16,10 +16,10 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; 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'; import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
defineProps<TeamsIndexPageProps>(); const props = defineProps<TeamsIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
@@ -27,6 +27,15 @@ const breadcrumbs: BreadcrumbItem[] = [
href: teamsIndex(), 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> </script>
<template> <template>
@@ -39,12 +48,16 @@ const breadcrumbs: BreadcrumbItem[] = [
> >
<Heading <Heading
title="Teams" 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"> <Badge variant="outline" class="gap-1.5 px-3 py-1">
<Shield class="size-3.5" /> <Shield class="size-3.5" />
Admin-only area {{
canCreateTeams
? 'Global admin'
: 'Team-scoped access'
}}
</Badge> </Badge>
</div> </div>
@@ -55,8 +68,15 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ status }} {{ status }}
</div> </div>
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]"> <div
<Card> class="grid gap-6"
:class="
canCreateTeams
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
: 'xl:grid-cols-1'
"
>
<Card v-if="canCreateTeams">
<CardHeader> <CardHeader>
<CardTitle>Create team</CardTitle> <CardTitle>Create team</CardTitle>
<CardDescription> <CardDescription>
@@ -121,7 +141,7 @@ const breadcrumbs: BreadcrumbItem[] = [
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="pt-6"> <CardContent class="space-y-6 pt-6">
<Form <Form
v-bind=" v-bind="
TeamController.updateMembers.form(team.id) TeamController.updateMembers.form(team.id)
@@ -164,12 +184,177 @@ const breadcrumbs: BreadcrumbItem[] = [
Save members Save members
</Button> </Button>
</Form> </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> </CardContent>
</Card> </Card>
<Card v-if="teams.length === 0"> <Card v-if="teams.length === 0">
<CardContent class="text-muted-foreground p-6 text-sm"> <CardContent class="text-muted-foreground p-6 text-sm">
No teams exist yet. No teams are available for your current
permissions.
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+7
View File
@@ -2,7 +2,14 @@ import type { UserModel } from '@/types/models/user-model';
export type User = UserModel; export type User = UserModel;
export type AuthPermissions = {
canManageTeams: boolean;
globalAdmin: boolean;
projectCreate: boolean;
};
export type Auth = { export type Auth = {
permissions: AuthPermissions;
user: User; user: User;
}; };
-1
View File
@@ -4,7 +4,6 @@ export type UserModel = {
email: string; email: string;
avatar?: string; avatar?: string;
email_verified_at: string | null; email_verified_at: string | null;
is_admin: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
+7 -2
View File
@@ -17,6 +17,8 @@ import type { UserGitConnectionModel } from '@/types/models/user-git-connection-
import type { UserModel } from '@/types/models/user-model'; import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = { export type AdminUsersPageProps = {
directPermissions: Record<string, string[]>;
permissionOptions: PermissionOption[];
users: UserModel[]; users: UserModel[];
status?: string | null; status?: string | null;
}; };
@@ -31,14 +33,14 @@ export type ProjectShowPageProps = {
project: ProjectModel; project: ProjectModel;
}; };
export type ProjectPermissionOption = { export type PermissionOption = {
label: string; label: string;
value: string; value: string;
}; };
export type ProjectAccessPageProps = { export type ProjectAccessPageProps = {
directPermissions: Record<string, string[]>; directPermissions: Record<string, string[]>;
permissionOptions: ProjectPermissionOption[]; permissionOptions: PermissionOption[];
project: ProjectModel; project: ProjectModel;
status?: string | null; status?: string | null;
teamPermissions: Record<string, string[]>; teamPermissions: Record<string, string[]>;
@@ -47,7 +49,10 @@ export type ProjectAccessPageProps = {
}; };
export type TeamsIndexPageProps = { export type TeamsIndexPageProps = {
canCreateTeams: boolean;
permissionOptions: PermissionOption[];
status?: string | null; status?: string | null;
teamPermissions: Record<string, Record<string, string[]>>;
teams: TeamModel[]; teams: TeamModel[];
users: UserModel[]; users: UserModel[];
}; };
+12 -5
View File
@@ -103,6 +103,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType']) Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
->name('projects.settings.metadata.link-types.destroy'); ->name('projects.settings.metadata.link-types.destroy');
}); });
Route::get('teams', [TeamController::class, 'index'])
->middleware('can:manage-teams')
->name('teams.index');
Route::post('teams', [TeamController::class, 'store'])
->middleware('can:access-admin')
->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']) Route::middleware(['auth', 'verified', 'can:access-admin'])
@@ -110,11 +121,7 @@ Route::middleware(['auth', 'verified', 'can:access-admin'])
->name('admin.') ->name('admin.')
->group(function () { ->group(function () {
Route::get('users', [UserManagementController::class, 'index'])->name('users.index'); Route::get('users', [UserManagementController::class, 'index'])->name('users.index');
Route::patch('users/{user}/admin-status', [UserManagementController::class, 'update'])->name('users.update'); Route::patch('users/{user}/permissions', [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');
}); });
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';
@@ -27,10 +27,10 @@ class UserManagementControllerTest extends TestCase
->assertOk() ->assertOk()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('admin/users/Index') ->component('admin/users/Index')
->where('users.0.email', $admin->email) ->has('users', 2)
->where('users.1.email', $member->email) ->where("directPermissions.{$admin->id}.0", 'global:admin')
->where('users.0.is_admin', true) ->where('permissionOptions.0.value', 'global:admin')
->where('users.1.is_admin', false), ->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(); $admin = User::factory()->admin()->create();
$member = User::factory()->create([ $member = User::factory()->create([
@@ -58,30 +58,72 @@ class UserManagementControllerTest extends TestCase
$this->actingAs($admin) $this->actingAs($admin)
->patch(route('admin.users.update', $member), [ ->patch(route('admin.users.update', $member), [
'is_admin' => true, 'permissions' => ['global:admin', 'project:create'],
]) ])
->assertRedirect(route('admin.users.index')) ->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(); $member = User::factory()->create();
$otherUser = User::factory()->create([ $otherUser = User::factory()->create();
'is_admin' => false,
]);
$this->actingAs($member) $this->actingAs($member)
->patch(route('admin.users.update', $otherUser), [ ->patch(route('admin.users.update', $otherUser), [
'is_admin' => true, 'permissions' => ['global:admin'],
]) ])
->assertForbidden(); ->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,
]);
}
}
+13 -9
View File
@@ -66,20 +66,24 @@ class TeamTest extends TestCase
ProjectAccessSeeder::class, ProjectAccessSeeder::class,
]); ]);
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail(); $coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$platformTeam = Team::query()->where('name', 'Platform Team')->firstOrFail(); $platformTeam = Team::query()->where('name', 'Platform Team')->firstOrFail();
$testUser = User::query()->where('email', 'test@example.com')->firstOrFail();
$this->assertDatabaseHas('project_user_permissions', [ $this->assertDatabaseHas('permission_assignments', [
'project_id' => $coreProject->id, 'assignee_id' => $platformTeam->id,
'user_id' => $admin->id, 'assignee_type' => $platformTeam->getMorphClass(),
'permission' => 'view', 'permission' => 'project:write',
'scope_id' => $coreProject->id,
'scope_type' => $coreProject->getMorphClass(),
]); ]);
$this->assertDatabaseHas('project_team_permissions', [ $this->assertDatabaseHas('permission_assignments', [
'project_id' => $coreProject->id, 'assignee_id' => $testUser->id,
'team_id' => $platformTeam->id, 'assignee_type' => $testUser->getMorphClass(),
'permission' => 'edit', 'permission' => 'team:manage',
'scope_id' => $platformTeam->id,
'scope_type' => $platformTeam->getMorphClass(),
]); ]);
} }
} }
+31 -10
View File
@@ -13,13 +13,19 @@ class UserTest extends TestCase
use RefreshDatabase; 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(); $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(); $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->seed(UserSeeder::class);
$this->assertDatabaseHas('users', [ $admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
'email' => 'admin@kanbangit.test', $member = User::query()->where('email', 'test@example.com')->firstOrFail();
'is_admin' => true,
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $admin->id,
'assignee_type' => $admin->getMorphClass(),
'permission' => 'global:admin',
'scope_id' => null,
'scope_type' => null,
]); ]);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('permission_assignments', [
'email' => 'test@example.com', 'assignee_id' => $member->id,
'is_admin' => false, '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; namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
@@ -47,7 +46,7 @@ class ProjectBacklogControllerTest extends TestCase
'title' => 'Seed backlog item', 'title' => 'Seed backlog item',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.backlog.index', $project)) ->get(route('projects.backlog.index', $project))
@@ -77,7 +76,7 @@ class ProjectBacklogControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.backlog.store', $project), [ ->post(route('projects.backlog.store', $project), [
@@ -107,7 +106,7 @@ class ProjectBacklogControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['create', 'view']); $this->syncProjectPermissions($project, $user, ['create', 'view']);
$payload = [ $payload = [
'title' => 'Same title', 'title' => 'Same title',
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
@@ -63,7 +62,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket', 'title' => 'Board ticket',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.board.index', $project)) ->get(route('projects.board.index', $project))
@@ -113,7 +112,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Backlog ticket', 'title' => 'Backlog ticket',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [ ->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -172,7 +171,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket', 'title' => 'Board ticket',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [ ->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -227,7 +226,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket', 'title' => 'Board ticket',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [ ->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -280,7 +279,7 @@ class ProjectBoardControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->from(route('projects.backlog.index', $project)) ->from(route('projects.backlog.index', $project))
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use App\Models\User; use App\Models\User;
@@ -28,7 +27,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.settings.board', $project)) ->get(route('projects.settings.board', $project))
@@ -63,7 +62,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'key' => 'CORE', 'key' => 'CORE',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.board.columns.store', $project), [ ->post(route('projects.settings.board.columns.store', $project), [
@@ -99,7 +98,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 3, 'position' => 3,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->patch(route('projects.settings.board.columns.update', [$project, $thirdColumn]), [ ->patch(route('projects.settings.board.columns.update', [$project, $thirdColumn]), [
@@ -139,7 +138,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 3, 'position' => 3,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn])) ->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn]))
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
@@ -80,9 +79,8 @@ class ProjectControllerTest extends TestCase
'key' => 'HIDE', 'key' => 'HIDE',
]); ]);
$projectPermissionService = app(ProjectPermissionService::class); $this->syncProjectPermissions($directProject, $user, ['view']);
$projectPermissionService->syncUserPermissions($directProject, $user, ['view']); $this->syncProjectPermissions($teamProject, $team, ['view']);
$projectPermissionService->syncTeamPermissions($teamProject, $team, ['view']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.index')) ->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 public function test_authenticated_users_can_view_the_project_creation_screen(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.create')) ->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 public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->post(route('projects.store'), [ ->post(route('projects.store'), [
@@ -132,13 +144,13 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE', 'key' => 'CORE',
]); ]);
foreach (ProjectPermissionService::permissions() as $permission) { $this->assertDatabaseHas('permission_assignments', [
$this->assertDatabaseHas('project_user_permissions', [ 'assignee_id' => $user->id,
'project_id' => $project->id, 'assignee_type' => $user->getMorphClass(),
'user_id' => $user->id, 'permission' => 'project:admin',
'permission' => $permission, 'scope_id' => $project->id,
]); 'scope_type' => $project->getMorphClass(),
} ]);
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id, 'project_id' => $project->id,
@@ -161,6 +173,7 @@ class ProjectControllerTest extends TestCase
public function test_project_keys_must_be_unique_case_insensitively(): void public function test_project_keys_must_be_unique_case_insensitively(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
Project::factory()->create([ Project::factory()->create([
'key' => 'AUTH', 'key' => 'AUTH',
@@ -182,6 +195,7 @@ class ProjectControllerTest extends TestCase
public function test_project_keys_may_not_exceed_five_characters(): void public function test_project_keys_may_not_exceed_five_characters(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user) $this->actingAs($user)
->from(route('projects.create')) ->from(route('projects.create'))
@@ -203,7 +217,7 @@ class ProjectControllerTest extends TestCase
'name' => 'Core Platform', 'name' => 'Core Platform',
'key' => 'CORE', 'key' => 'CORE',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.show', $project)) ->get(route('projects.show', $project))
@@ -228,7 +242,7 @@ class ProjectControllerTest extends TestCase
]); ]);
$team->users()->attach($user); $team->users()->attach($user);
app(ProjectPermissionService::class)->syncTeamPermissions($project, $team, ['view']); $this->syncProjectPermissions($project, $team, ['view']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.show', $project)) ->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(); $user = User::factory()->create();
$project = Project::factory()->create([ $project = Project::factory()->create([
@@ -265,7 +279,7 @@ class ProjectControllerTest extends TestCase
'name' => 'Platform Team', 'name' => 'Platform Team',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.access', $project)) ->get(route('projects.access', $project))
@@ -275,7 +289,7 @@ class ProjectControllerTest extends TestCase
->where('project.id', $project->id) ->where('project.id', $project->id)
->has('users', 1) ->has('users', 1)
->has('teams', 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', 'key' => 'CORE',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']); $this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.settings.general', $project)) ->get(route('projects.settings.general', $project))
@@ -348,7 +362,7 @@ class ProjectControllerTest extends TestCase
'title' => 'Delete me', 'title' => 'Delete me',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $member, ['view']); $this->syncProjectPermissions($project, $member, ['view']);
$this->actingAs($admin) $this->actingAs($admin)
->delete(route('projects.destroy', $project), [ ->delete(route('projects.destroy', $project), [
@@ -366,10 +380,12 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseMissing('tickets', [ $this->assertDatabaseMissing('tickets', [
'id' => $ticket->id, 'id' => $ticket->id,
]); ]);
$this->assertDatabaseMissing('project_user_permissions', [ $this->assertDatabaseMissing('permission_assignments', [
'project_id' => $project->id, 'assignee_id' => $member->id,
'user_id' => $member->id, 'assignee_type' => $member->getMorphClass(),
'permission' => 'view', 'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]); ]);
} }
@@ -406,7 +422,7 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE', 'key' => 'CORE',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']); $this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.destroy', $project), [ ->delete(route('projects.destroy', $project), [
@@ -434,28 +450,32 @@ class ProjectControllerTest extends TestCase
$this->actingAs($admin) $this->actingAs($admin)
->patch(route('projects.permissions.users.update', [$project, $member]), [ ->patch(route('projects.permissions.users.update', [$project, $member]), [
'permissions' => ['view', 'edit'], 'permissions' => ['project:read', 'project:write'],
]) ])
->assertRedirect(route('projects.access', $project)) ->assertRedirect(route('projects.access', $project))
->assertSessionHas('status', 'Regular User permissions were updated.'); ->assertSessionHas('status', 'Regular User permissions were updated.');
$this->assertDatabaseHas('project_user_permissions', [ $this->assertDatabaseHas('permission_assignments', [
'project_id' => $project->id, 'assignee_id' => $member->id,
'user_id' => $member->id, 'assignee_type' => $member->getMorphClass(),
'permission' => 'view', 'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]); ]);
$this->assertDatabaseHas('project_user_permissions', [ $this->assertDatabaseHas('permission_assignments', [
'project_id' => $project->id, 'assignee_id' => $member->id,
'user_id' => $member->id, 'assignee_type' => $member->getMorphClass(),
'permission' => 'edit', '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(); $manager = User::factory()->create();
$team = Team::factory()->create([ $team = Team::factory()->create([
@@ -465,25 +485,29 @@ class ProjectControllerTest extends TestCase
'key' => 'OPS', 'key' => 'OPS',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $manager, ['edit']); $this->syncProjectPermissions($project, $manager, ['admin']);
$this->actingAs($manager) $this->actingAs($manager)
->patch(route('projects.permissions.teams.update', [$project, $team]), [ ->patch(route('projects.permissions.teams.update', [$project, $team]), [
'permissions' => ['view', 'create'], 'permissions' => ['project:read', 'project:write'],
]) ])
->assertRedirect(route('projects.access', $project)) ->assertRedirect(route('projects.access', $project))
->assertSessionHas('status', 'Delivery Team permissions were updated.'); ->assertSessionHas('status', 'Delivery Team permissions were updated.');
$this->assertDatabaseHas('project_team_permissions', [ $this->assertDatabaseHas('permission_assignments', [
'project_id' => $project->id, 'assignee_id' => $team->id,
'team_id' => $team->id, 'assignee_type' => $team->getMorphClass(),
'permission' => 'view', 'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]); ]);
$this->assertDatabaseHas('project_team_permissions', [ $this->assertDatabaseHas('permission_assignments', [
'project_id' => $project->id, 'assignee_id' => $team->id,
'team_id' => $team->id, 'assignee_type' => $team->getMorphClass(),
'permission' => 'create', 'permission' => 'project:write',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]); ]);
} }
} }
@@ -3,7 +3,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey; use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project; use App\Models\Project;
use App\Models\RepositoryConnection; use App\Models\RepositoryConnection;
use App\Models\User; use App\Models\User;
@@ -37,7 +36,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'repository_url' => 'https://git.example.test/core-team/kanbangit', 'repository_url' => 'https://git.example.test/core-team/kanbangit',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.settings.repositories', $project)) ->get(route('projects.settings.repositories', $project))
@@ -92,7 +91,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider' => GitProviderKey::Gitea, 'provider' => GitProviderKey::Gitea,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.repositories.store', $project), [ ->post(route('projects.settings.repositories.store', $project), [
@@ -126,7 +125,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
$project = Project::factory()->create(); $project = Project::factory()->create();
$foreignGitConnection = UserGitConnection::factory()->for($otherUser)->create(); $foreignGitConnection = UserGitConnection::factory()->for($otherUser)->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.repositories.store', $project), [ ->post(route('projects.settings.repositories.store', $project), [
@@ -173,7 +172,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider' => GitProviderKey::Gitea, 'provider' => GitProviderKey::Gitea,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->patch(route('projects.settings.repositories.update', [$project, $repositoryConnection]), [ ->patch(route('projects.settings.repositories.update', [$project, $repositoryConnection]), [
@@ -204,7 +203,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'repository_full_name' => 'core-team/kanbangit', 'repository_full_name' => 'core-team/kanbangit',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.settings.repositories.destroy', [$project, $repositoryConnection])) ->delete(route('projects.settings.repositories.destroy', [$project, $repositoryConnection]))
@@ -3,7 +3,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey; use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project; use App\Models\Project;
use App\Models\RepositoryConnection; use App\Models\RepositoryConnection;
@@ -48,7 +47,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea, 'provider' => GitProviderKey::Gitea,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [ ->getJson(route('projects.tickets.branches.validate', [
@@ -92,7 +91,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea, 'provider' => GitProviderKey::Gitea,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [ ->getJson(route('projects.tickets.branches.validate', [
@@ -128,7 +127,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'repository_url' => 'https://git.example.test/core-team/kanbangit', '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) $this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [ ->getJson(route('projects.tickets.branches.validate', [
@@ -188,7 +187,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea, 'provider' => GitProviderKey::Gitea,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.tickets.branches.store', [$project, $ticket]), [ ->post(route('projects.tickets.branches.store', [$project, $ticket]), [
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project; use App\Models\Project;
use App\Models\RepositoryConnection; use App\Models\RepositoryConnection;
@@ -86,7 +85,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']); $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer) $this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $ticket])) ->get(route('projects.tickets.show', [$project, $ticket]))
@@ -196,7 +195,7 @@ class ProjectTicketControllerTest extends TestCase
'base_url' => 'https://git.example.test', 'base_url' => 'https://git.example.test',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']); $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer) $this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $ticket])) ->get(route('projects.tickets.show', [$project, $ticket]))
@@ -244,7 +243,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']); $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer) $this->actingAs($viewer)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -325,7 +324,7 @@ class ProjectTicketControllerTest extends TestCase
'value_user_id' => null, 'value_user_id' => null,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -417,7 +416,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Child ticket', 'title' => 'Child ticket',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $childTicket])) ->from(route('projects.tickets.show', [$project, $childTicket]))
@@ -474,7 +473,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Child ticket', 'title' => 'Child ticket',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $childTicket]), [ ->patch(route('projects.tickets.update', [$project, $childTicket]), [
@@ -563,7 +562,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Nested task', 'title' => 'Nested task',
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']); $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer) $this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $rootParent])) ->get(route('projects.tickets.show', [$project, $rootParent]))
@@ -596,7 +595,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $ticket])) ->from(route('projects.tickets.show', [$project, $ticket]))
@@ -645,7 +644,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -53,7 +52,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_type_id' => $taskType->id, 'ticket_type_id' => $taskType->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $ticket]), [ ->post(route('projects.tickets.links.store', [$project, $ticket]), [
@@ -118,7 +117,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_type_id' => $otherTaskType->id, 'ticket_type_id' => $otherTaskType->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->from(route('projects.tickets.show', [$project, $ticket])) ->from(route('projects.tickets.show', [$project, $ticket]))
@@ -180,7 +179,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_link_type_id' => $ticketLinkType->id, 'ticket_link_type_id' => $ticketLinkType->id,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink])) ->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink]))
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
@@ -33,7 +32,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.settings.metadata', $project)) ->get(route('projects.settings.metadata', $project))
@@ -55,7 +54,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$project = Project::factory()->create(); $project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.metadata.types.store', $project), [ ->post(route('projects.settings.metadata.types.store', $project), [
@@ -80,7 +79,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$project = Project::factory()->create(); $project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$taskType = TicketType::query() $taskType = TicketType::query()
->where('project_id', $project->id) ->where('project_id', $project->id)
@@ -130,7 +129,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.metadata.priorities.store', $project), [ ->post(route('projects.settings.metadata.priorities.store', $project), [
@@ -169,7 +168,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.metadata.fields.store', $project), [ ->post(route('projects.settings.metadata.fields.store', $project), [
@@ -203,7 +202,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$project = Project::factory()->create(); $project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->from(route('projects.settings.metadata', $project)) ->from(route('projects.settings.metadata', $project))
@@ -227,7 +226,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.metadata.link-types.store', $project), [ ->post(route('projects.settings.metadata.link-types.store', $project), [
+49 -9
View File
@@ -28,10 +28,11 @@ class TeamControllerTest extends TestCase
$team->users()->attach($member); $team->users()->attach($member);
$this->actingAs($admin) $this->actingAs($admin)
->get(route('admin.teams.index')) ->get(route('teams.index'))
->assertOk() ->assertOk()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('teams/Index') ->component('teams/Index')
->where('canCreateTeams', true)
->where('teams.0.name', 'Platform Team') ->where('teams.0.name', 'Platform Team')
->where('teams.0.user_ids.0', $member->id) ->where('teams.0.user_ids.0', $member->id)
->where('teams.0.users.0.email', 'member@example.com'), ->where('teams.0.users.0.email', 'member@example.com'),
@@ -46,10 +47,10 @@ class TeamControllerTest extends TestCase
$admin = User::factory()->admin()->create(); $admin = User::factory()->admin()->create();
$this->actingAs($admin) $this->actingAs($admin)
->post(route('admin.teams.store'), [ ->post(route('teams.store'), [
'name' => 'Delivery Team', 'name' => 'Delivery Team',
]) ])
->assertRedirect(route('admin.teams.index')) ->assertRedirect(route('teams.index'))
->assertSessionHas('status', 'Delivery Team was created.'); ->assertSessionHas('status', 'Delivery Team was created.');
$this->assertDatabaseHas('teams', [ $this->assertDatabaseHas('teams', [
@@ -70,10 +71,10 @@ class TeamControllerTest extends TestCase
]); ]);
$this->actingAs($admin) $this->actingAs($admin)
->patch(route('admin.teams.members.update', $team), [ ->patch(route('teams.members.update', $team), [
'user_ids' => [$firstMember->id, $secondMember->id], 'user_ids' => [$firstMember->id, $secondMember->id],
]) ])
->assertRedirect(route('admin.teams.index')) ->assertRedirect(route('teams.index'))
->assertSessionHas('status', 'Platform Team members were updated.'); ->assertSessionHas('status', 'Platform Team members were updated.');
$this->assertDatabaseHas('team_user', [ $this->assertDatabaseHas('team_user', [
@@ -88,14 +89,14 @@ class TeamControllerTest extends TestCase
} }
/** /**
* Ensure non-admin users cannot view the teams management page. * Ensure users without team management access cannot view the teams page.
*/ */
public function test_non_admins_cannot_view_the_teams_management_page(): void public function test_non_admins_cannot_view_the_teams_management_page(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user) $this->actingAs($user)
->get(route('admin.teams.index')) ->get(route('teams.index'))
->assertForbidden(); ->assertForbidden();
} }
@@ -107,7 +108,7 @@ class TeamControllerTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user) $this->actingAs($user)
->post(route('admin.teams.store'), [ ->post(route('teams.store'), [
'name' => 'Platform Team', 'name' => 'Platform Team',
]) ])
->assertForbidden(); ->assertForbidden();
@@ -122,9 +123,48 @@ class TeamControllerTest extends TestCase
$team = Team::factory()->create(); $team = Team::factory()->create();
$this->actingAs($user) $this->actingAs($user)
->patch(route('admin.teams.members.update', $team), [ ->patch(route('teams.members.update', $team), [
'user_ids' => [], 'user_ids' => [],
]) ])
->assertForbidden(); ->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(),
]);
}
} }
+80
View File
@@ -2,6 +2,11 @@
namespace Tests; 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 Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
@@ -13,4 +18,79 @@ abstract class TestCase extends BaseTestCase
$this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled."); $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();
}
} }