Implement project memberships teams and permission model #6
@@ -0,0 +1,247 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,42 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
use App\Http\Requests\StoreProjectRequest;
|
use App\Http\Requests\StoreProjectRequest;
|
||||||
|
use App\Http\Requests\SyncProjectPermissionsRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class ProjectController extends Controller
|
class ProjectController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): Response
|
public function __construct(
|
||||||
|
private readonly ProjectPermissionService $projectPermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$projectQuery = Project::query()
|
||||||
|
->orderByDesc('id');
|
||||||
|
|
||||||
|
if (! $request->user()->is_admin) {
|
||||||
|
$viewableProjectIds = $this->projectPermissionService->viewableProjectIds($request->user());
|
||||||
|
|
||||||
|
if ($viewableProjectIds === []) {
|
||||||
|
return Inertia::render('projects/Index', [
|
||||||
|
'projects' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectQuery->whereIn('id', $viewableProjectIds);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('projects/Index', [
|
return Inertia::render('projects/Index', [
|
||||||
'projects' => Project::query()
|
'projects' => $projectQuery
|
||||||
->orderByDesc('id')
|
|
||||||
->get(),
|
->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -27,14 +50,84 @@ class ProjectController extends Controller
|
|||||||
public function store(StoreProjectRequest $request): RedirectResponse
|
public function store(StoreProjectRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$project = Project::query()->create($request->validated());
|
$project = Project::query()->create($request->validated());
|
||||||
|
$this->projectPermissionService->grantAllProjectPermissions($project, $request->user());
|
||||||
|
|
||||||
return to_route('projects.show', $project);
|
return to_route('projects.show', $project);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Project $project): Response
|
public function show(Request $request, Project $project): Response
|
||||||
{
|
{
|
||||||
|
abort_unless(
|
||||||
|
$this->projectPermissionService->userCanViewProject($request->user(), $project),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
return Inertia::render('projects/Show', [
|
return Inertia::render('projects/Show', [
|
||||||
|
'canManageAccess' => $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function access(Request $request, Project $project): Response
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Inertia::render('projects/Access', [
|
||||||
|
'directPermissions' => $this->projectPermissionService->userPermissionMap($project),
|
||||||
|
'permissionOptions' => $this->projectPermissionService->permissionOptions(),
|
||||||
|
'project' => $project,
|
||||||
|
'status' => session('status'),
|
||||||
|
'teamPermissions' => $this->projectPermissionService->teamPermissionMap($project),
|
||||||
|
'teams' => Team::query()
|
||||||
|
->with('users')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn (Team $team): array => [
|
||||||
|
'id' => $team->id,
|
||||||
|
'name' => $team->name,
|
||||||
|
'created_at' => $team->created_at?->toISOString(),
|
||||||
|
'updated_at' => $team->updated_at?->toISOString(),
|
||||||
|
'user_ids' => $team->users->modelKeys(),
|
||||||
|
'users' => $team->users->values(),
|
||||||
|
]),
|
||||||
|
'users' => User::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateUserPermissions(
|
||||||
|
SyncProjectPermissionsRequest $request,
|
||||||
|
Project $project,
|
||||||
|
User $user,
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless(
|
||||||
|
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->projectPermissionService->syncUserPermissions($project, $user, $request->permissions());
|
||||||
|
|
||||||
|
return to_route('projects.access', $project)
|
||||||
|
->with('status', "{$user->name} permissions were updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTeamPermissions(
|
||||||
|
SyncProjectPermissionsRequest $request,
|
||||||
|
Project $project,
|
||||||
|
Team $team,
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless(
|
||||||
|
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->projectPermissionService->syncTeamPermissions($project, $team, $request->permissions());
|
||||||
|
|
||||||
|
return to_route('projects.access', $project)
|
||||||
|
->with('status', "{$team->name} permissions were updated.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
|
use App\Http\Requests\StoreTeamRequest;
|
||||||
|
use App\Http\Requests\UpdateTeamMembersRequest;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class TeamController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProjectPermissionService $projectPermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('teams/Index', [
|
||||||
|
'status' => session('status'),
|
||||||
|
'teams' => Team::query()
|
||||||
|
->with('users')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn (Team $team): array => [
|
||||||
|
'id' => $team->id,
|
||||||
|
'name' => $team->name,
|
||||||
|
'created_at' => $team->created_at?->toISOString(),
|
||||||
|
'updated_at' => $team->updated_at?->toISOString(),
|
||||||
|
'user_ids' => $team->users->modelKeys(),
|
||||||
|
'users' => $team->users->values(),
|
||||||
|
]),
|
||||||
|
'users' => User::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreTeamRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$team = Team::query()->create($request->validated());
|
||||||
|
|
||||||
|
return to_route('admin.teams.index')
|
||||||
|
->with('status', "{$team->name} was created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateMembers(UpdateTeamMembersRequest $request, Team $team): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->projectPermissionService->syncTeamMembers($team, $request->userIds());
|
||||||
|
|
||||||
|
return to_route('admin.teams.index')
|
||||||
|
->with('status', "{$team->name} members were updated.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreTeamRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('access-admin') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('teams', 'name'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class SyncProjectPermissionsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
Twyco marked this conversation as resolved
|
|||||||
|
{
|
||||||
|
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',
|
||||||
|
Rule::in(ProjectPermissionService::permissions()),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return collect($this->validated('permissions', []))
|
||||||
|
->map(fn (mixed $permission): string => (string) $permission)
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateTeamMembersRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('access-admin') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_ids' => ['nullable', 'array'],
|
||||||
|
'user_ids.*' => ['integer', Rule::exists('users', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
public function userIds(): array
|
||||||
|
{
|
||||||
|
return collect($this->validated('user_ids', []))
|
||||||
|
->map(fn (mixed $userId): int => (int) $userId)
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\TeamFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
#[Fillable(['name'])]
|
||||||
|
class Team extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<TeamFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public function users(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class)
|
||||||
|
->withTimestamps();
|
||||||
|
Twyco marked this conversation as resolved
Eric
commented
was macht withTimeStamps also warum nötig was macht withTimeStamps also warum nötig
Twyco
commented
weil es ist eine m:n beziehung, d.h. es gibt eine eigene table nur für user und team. damit die auch timestamps hat schreibt man das hier, dann siehst du wann der user dem team hinzugefügt wurde weil es ist eine m:n beziehung, d.h. es gibt eine eigene table nur für user und team. damit die auch timestamps hat schreibt man das hier, dann siehst du wann der user dem team hinzugefügt wurde
|
|||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use Database\Factories\UserFactory;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
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\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;
|
||||||
@@ -32,4 +33,10 @@ class User extends Authenticatable
|
|||||||
'two_factor_confirmed_at' => 'datetime',
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function teams(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Team::class)
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Team>
|
||||||
|
*/
|
||||||
|
class TeamFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->unique()->company().' Team',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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,30 @@
|
|||||||
|
<?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('team_user', function (Blueprint $table) {
|
||||||
|
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
Twyco
commented
@Eric hier diese timestamps werden damit geused. Team_User ist ja kein MOdels nur die pivot table zwischen zwei models. Deswgen auch keine auto timestamps nur halt mit der expilziten angabe bei relation @Eric hier diese timestamps werden damit geused. Team_User ist ja kein MOdels nur die pivot table zwischen zwei models. Deswgen auch keine auto timestamps nur halt mit der expilziten angabe bei relation
|
|||||||
|
$table->primary(['team_id', 'user_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('team_user');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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('teams', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('teams');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,8 +13,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
ProjectSeeder::class,
|
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
|
ProjectSeeder::class,
|
||||||
|
TeamSeeder::class,
|
||||||
|
ProjectAccessSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class ProjectAccessSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
$platformTeam = Team::query()->where('name', 'Platform Team')->first();
|
||||||
|
|
||||||
|
if ($coreProject !== null && $platformTeam !== null) {
|
||||||
|
$projectPermissionService->syncTeamPermissions($coreProject, $platformTeam, [
|
||||||
|
'create',
|
||||||
|
'edit',
|
||||||
|
'view',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class TeamSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$users = User::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$platformTeam = Team::factory()->create([
|
||||||
|
'name' => 'Platform Team',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deliveryTeam = Team::factory()->create([
|
||||||
|
'name' => 'Delivery Team',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$platformTeam->users()->sync($users->take(3)->modelKeys());
|
||||||
|
$deliveryTeam->users()->sync($users->slice(3, 3)->values()->modelKeys());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,16 @@
|
|||||||
|
|
||||||
### Ticket
|
### Ticket
|
||||||
|
|
||||||
|
- general:
|
||||||
|
- new skill, user resources
|
||||||
|
|
||||||
- 02:
|
- 02:
|
||||||
- Delete Projekt as Admin
|
- Delete Projekt as Admin
|
||||||
|
- 03
|
||||||
|
- permission models? eg.g syncUserPermissions uses only db table
|
||||||
|
- mit morph auf perm user (z.b. team oder user)
|
||||||
|
- Project Permissions Service
|
||||||
|
- syncTeamMembers(Team $team, array $userIds): void → ist wrong here
|
||||||
|
- gate statt has permission methods
|
||||||
|
- permission for creating teams without beeing admind -> teamowner
|
||||||
|
- team roles
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FolderGit2,
|
FolderGit2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Shield,
|
Shield,
|
||||||
|
Users,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
@@ -21,9 +22,10 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { dashboard } from '@/routes';
|
||||||
|
import { index as adminTeams } from '@/routes/admin/teams';
|
||||||
import { index as adminUsers } from '@/routes/admin/users';
|
import { index as adminUsers } from '@/routes/admin/users';
|
||||||
import { index as projectsIndex } from '@/routes/projects';
|
import { index as projectsIndex } from '@/routes/projects';
|
||||||
import { dashboard } from '@/routes';
|
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
@@ -48,6 +50,12 @@ const mainNavItems = computed<NavItem[]>(() => {
|
|||||||
href: adminUsers(),
|
href: adminUsers(),
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
title: 'Teams',
|
||||||
|
href: adminTeams(),
|
||||||
|
icon: Users,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ watch(
|
|||||||
async (isOpen) => {
|
async (isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
resetModalState();
|
resetModalState();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
|
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { access, index, show } from '@/routes/projects';
|
||||||
|
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<ProjectAccessPageProps>();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Projects',
|
||||||
|
href: index(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: props.project.name,
|
||||||
|
href: show(props.project.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Access',
|
||||||
|
href: access(props.project.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasPermission = (
|
||||||
|
permissions: Record<string, string[]>,
|
||||||
|
actorId: number,
|
||||||
|
permission: string,
|
||||||
|
) => (permissions[actorId] ?? []).includes(permission);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head :title="`${project.name} access`" />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Badge variant="outline" class="px-3 py-1">
|
||||||
|
{{ project.key }}
|
||||||
|
</Badge>
|
||||||
|
<Heading
|
||||||
|
:title="`${project.name} access`"
|
||||||
|
description="Assign direct user permissions or inherited team permissions for this project."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="show(project.id)">Back to project</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="status"
|
||||||
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
|
>
|
||||||
|
{{ status }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="gap-0 overflow-hidden">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<CardTitle>User permissions</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Direct assignments apply only to the selected user
|
||||||
|
inside this project.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table
|
||||||
|
class="min-w-full divide-y divide-border text-sm"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-muted/40 text-left text-muted-foreground"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">User</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Email</th>
|
||||||
|
<th class="px-4 py-3 font-medium">
|
||||||
|
Permissions
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right font-medium"
|
||||||
|
>
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border">
|
||||||
|
<tr
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
class="bg-background align-top"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-4 py-4 font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 text-muted-foreground">
|
||||||
|
{{ user.email }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
ProjectController.updateUserPermissions.form(
|
||||||
|
[project.id, user.id],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex flex-wrap gap-3"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="`${user.id}-${option.value}`"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions[]"
|
||||||
|
:value="option.value"
|
||||||
|
:checked="
|
||||||
|
hasPermission(
|
||||||
|
directPermissions,
|
||||||
|
user.id,
|
||||||
|
option.value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="size-4 rounded border-input text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="w-full sm:hidden" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-4 py-4 text-right text-muted-foreground"
|
||||||
|
>
|
||||||
|
Direct
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="gap-0 overflow-hidden">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<CardTitle>Team permissions</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Team assignments are inherited by every current and
|
||||||
|
future member of that team.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="p-0">
|
||||||
|
<div
|
||||||
|
v-if="teams.length === 0"
|
||||||
|
class="p-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No teams exist yet. Create teams first to assign
|
||||||
|
inherited permissions.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table
|
||||||
|
class="min-w-full divide-y divide-border text-sm"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-muted/40 text-left text-muted-foreground"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">Team</th>
|
||||||
|
<th class="px-4 py-3 font-medium">
|
||||||
|
Members
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 font-medium">
|
||||||
|
Permissions
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right font-medium"
|
||||||
|
>
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border">
|
||||||
|
<tr
|
||||||
|
v-for="team in teams"
|
||||||
|
:key="team.id"
|
||||||
|
class="bg-background align-top"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-4 py-4 font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{{ team.name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="member in team.users"
|
||||||
|
:key="member.id"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ member.name }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="team.users.length === 0"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
No members assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
ProjectController.updateTeamPermissions.form(
|
||||||
|
[project.id, team.id],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex flex-wrap gap-3"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="`${team.id}-${option.value}`"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions[]"
|
||||||
|
:value="option.value"
|
||||||
|
:checked="
|
||||||
|
hasPermission(
|
||||||
|
teamPermissions,
|
||||||
|
team.id,
|
||||||
|
option.value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="size-4 rounded border-input text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="w-full sm:hidden" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-4 py-4 text-right text-muted-foreground"
|
||||||
|
>
|
||||||
|
Inherited
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { index, show } from '@/routes/projects';
|
import { access, index, show } from '@/routes/projects';
|
||||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectShowPageProps>();
|
const props = defineProps<ProjectShowPageProps>();
|
||||||
@@ -73,6 +73,12 @@ const projectAreas = [
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canManageAccess">
|
||||||
|
<Button as-child>
|
||||||
|
<Link :href="access(project.id)">Manage access</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="area in projectAreas"
|
v-for="area in projectAreas"
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
|
import { Shield } from 'lucide-vue-next';
|
||||||
|
import TeamController from '@/actions/App/Http/Controllers/TeamController';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { index as teamsIndex } from '@/routes/admin/teams';
|
||||||
|
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
||||||
|
|
||||||
|
defineProps<TeamsIndexPageProps>();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Teams',
|
||||||
|
href: teamsIndex(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head title="Teams" />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<Heading
|
||||||
|
title="Teams"
|
||||||
|
description="Create reusable teams and manage member assignments for inherited project permissions."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||||
|
<Shield class="size-3.5" />
|
||||||
|
Admin-only area
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="status"
|
||||||
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
|
>
|
||||||
|
{{ status }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create team</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Teams group users so project permissions can be
|
||||||
|
granted once and inherited by every member.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Form
|
||||||
|
v-bind="TeamController.store.form()"
|
||||||
|
class="space-y-4"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Team name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="Platform Team"
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" :disabled="processing">
|
||||||
|
Create team
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<Card
|
||||||
|
v-for="team in teams"
|
||||||
|
:key="team.id"
|
||||||
|
class="gap-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<CardTitle>{{ team.name }}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{ team.users.length }} member{{
|
||||||
|
team.users.length === 1 ? '' : 's'
|
||||||
|
}}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="member in team.users"
|
||||||
|
:key="member.id"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ member.name }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
TeamController.updateMembers.form(team.id)
|
||||||
|
"
|
||||||
|
class="space-y-4"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label :for="`team-members-${team.id}`">
|
||||||
|
Members
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
:id="`team-members-${team.id}`"
|
||||||
|
name="user_ids[]"
|
||||||
|
multiple
|
||||||
|
class="min-h-44 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
:selected="
|
||||||
|
team.user_ids.includes(user.id)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ user.name }} ({{ user.email }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Hold Cmd or Ctrl to select multiple
|
||||||
|
users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save members
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="teams.length === 0">
|
||||||
|
<CardContent class="p-6 text-sm text-muted-foreground">
|
||||||
|
No teams exist yet.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
|
export type TeamModel = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
user_ids: number[];
|
||||||
|
users: UserModel[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
Twyco marked this conversation as resolved
Eric
commented
Was diese lan Was diese lan
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProjectModel } from '@/types/models/project-model';
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
|
import type { TeamModel } from '@/types/models/team-model';
|
||||||
import type { UserModel } from '@/types/models/user-model';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
export type AdminUsersPageProps = {
|
export type AdminUsersPageProps = {
|
||||||
@@ -11,5 +12,27 @@ export type ProjectsIndexPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectShowPageProps = {
|
export type ProjectShowPageProps = {
|
||||||
|
canManageAccess: boolean;
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectPermissionOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectAccessPageProps = {
|
||||||
|
directPermissions: Record<string, string[]>;
|
||||||
|
permissionOptions: ProjectPermissionOption[];
|
||||||
|
project: ProjectModel;
|
||||||
|
status?: string | null;
|
||||||
|
teamPermissions: Record<string, string[]>;
|
||||||
|
teams: TeamModel[];
|
||||||
|
users: UserModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamsIndexPageProps = {
|
||||||
|
status?: string | null;
|
||||||
|
teams: TeamModel[];
|
||||||
|
users: UserModel[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Admin\UserManagementController;
|
use App\Http\Controllers\Admin\UserManagementController;
|
||||||
use App\Http\Controllers\ProjectController;
|
use App\Http\Controllers\ProjectController;
|
||||||
|
use App\Http\Controllers\TeamController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
@@ -14,6 +15,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
Route::resource('projects', ProjectController::class)
|
Route::resource('projects', ProjectController::class)
|
||||||
->only(['index', 'create', 'store', 'show']);
|
->only(['index', 'create', 'store', 'show']);
|
||||||
|
|
||||||
|
Route::get('projects/{project}/access', [ProjectController::class, 'access'])
|
||||||
|
->name('projects.access');
|
||||||
|
Route::patch('projects/{project}/permissions/users/{user}', [ProjectController::class, 'updateUserPermissions'])
|
||||||
|
->name('projects.permissions.users.update');
|
||||||
|
Route::patch('projects/{project}/permissions/teams/{team}', [ProjectController::class, 'updateTeamPermissions'])
|
||||||
|
->name('projects.permissions.teams.update');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified', 'can:access-admin'])
|
Route::middleware(['auth', 'verified', 'can:access-admin'])
|
||||||
@@ -22,6 +30,10 @@ Route::middleware(['auth', 'verified', 'can:access-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}/admin-status', [UserManagementController::class, 'update'])->name('users.update');
|
||||||
|
|
||||||
|
Route::get('teams', [TeamController::class, 'index'])->name('teams.index');
|
||||||
|
Route::post('teams', [TeamController::class, 'store'])->name('teams.store');
|
||||||
|
Route::patch('teams/{team}/members', [TeamController::class, 'updateMembers'])->name('teams.members.update');
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Models;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\ProjectAccessSeeder;
|
||||||
|
use Database\Seeders\ProjectSeeder;
|
||||||
|
use Database\Seeders\TeamSeeder;
|
||||||
|
use Database\Seeders\UserSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TeamTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the team factory creates a usable default name.
|
||||||
|
*/
|
||||||
|
public function test_team_factory_creates_a_name(): void
|
||||||
|
{
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
|
||||||
|
$this->assertNotSame('', $team->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure teams can have many users attached.
|
||||||
|
*/
|
||||||
|
public function test_team_users_relationship_can_be_synced(): void
|
||||||
|
{
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$users = User::factory()->count(2)->create();
|
||||||
|
|
||||||
|
$team->users()->sync($users->modelKeys());
|
||||||
|
|
||||||
|
$this->assertCount(2, $team->fresh()->users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the team seeder creates starter teams with members.
|
||||||
|
*/
|
||||||
|
public function test_team_seeder_creates_expected_teams_and_members(): void
|
||||||
|
{
|
||||||
|
$this->seed(UserSeeder::class);
|
||||||
|
$this->seed(TeamSeeder::class);
|
||||||
|
|
||||||
|
$platformTeam = Team::query()->where('name', 'Platform Team')->firstOrFail();
|
||||||
|
$deliveryTeam = Team::query()->where('name', 'Delivery Team')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $platformTeam->users()->count());
|
||||||
|
$this->assertGreaterThan(0, $deliveryTeam->users()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the project access seeder grants direct admin access and team permissions.
|
||||||
|
*/
|
||||||
|
public function test_project_access_seeder_creates_permission_assignments(): void
|
||||||
|
{
|
||||||
|
$this->seed([
|
||||||
|
UserSeeder::class,
|
||||||
|
ProjectSeeder::class,
|
||||||
|
TeamSeeder::class,
|
||||||
|
ProjectAccessSeeder::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
|
||||||
|
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||||
|
$platformTeam = Team::query()->where('name', 'Platform Team')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_user_permissions', [
|
||||||
|
'project_id' => $coreProject->id,
|
||||||
|
'user_id' => $admin->id,
|
||||||
|
'permission' => 'view',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_team_permissions', [
|
||||||
|
'project_id' => $coreProject->id,
|
||||||
|
'team_id' => $platformTeam->id,
|
||||||
|
'permission' => 'edit',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Models;
|
namespace Tests\Feature\Models;
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\UserSeeder;
|
use Database\Seeders\UserSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -48,4 +49,17 @@ class UserTest extends TestCase
|
|||||||
'is_admin' => false,
|
'is_admin' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure users can belong to teams.
|
||||||
|
*/
|
||||||
|
public function test_users_can_belong_to_teams(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$teams = Team::factory()->count(2)->create();
|
||||||
|
|
||||||
|
$user->teams()->sync($teams->modelKeys());
|
||||||
|
|
||||||
|
$this->assertCount(2, $user->fresh()->teams);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Projects;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -29,7 +31,7 @@ class ProjectControllerTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
public function test_authenticated_users_can_view_the_projects_index(): void
|
public function test_authenticated_users_can_view_the_projects_index(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->admin()->create();
|
||||||
$firstProject = Project::factory()->create([
|
$firstProject = Project::factory()->create([
|
||||||
'name' => 'Core Platform',
|
'name' => 'Core Platform',
|
||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
@@ -50,6 +52,45 @@ class ProjectControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure non-admin users only see projects they can access directly or through teams.
|
||||||
|
*/
|
||||||
|
public function test_users_only_see_accessible_projects_on_the_index(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$team = Team::factory()->create([
|
||||||
|
'name' => 'Platform Team',
|
||||||
|
]);
|
||||||
|
$team->users()->attach($user);
|
||||||
|
|
||||||
|
$directProject = Project::factory()->create([
|
||||||
|
'name' => 'Direct Access',
|
||||||
|
'key' => 'DIR',
|
||||||
|
]);
|
||||||
|
$teamProject = Project::factory()->create([
|
||||||
|
'name' => 'Team Access',
|
||||||
|
'key' => 'TEAM',
|
||||||
|
]);
|
||||||
|
Project::factory()->create([
|
||||||
|
'name' => 'Hidden Project',
|
||||||
|
'key' => 'HIDE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$projectPermissionService = app(ProjectPermissionService::class);
|
||||||
|
$projectPermissionService->syncUserPermissions($directProject, $user, ['view']);
|
||||||
|
$projectPermissionService->syncTeamPermissions($teamProject, $team, ['view']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('projects.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Index')
|
||||||
|
->has('projects', 2)
|
||||||
|
->where('projects.0.id', $teamProject->id)
|
||||||
|
->where('projects.1.id', $directProject->id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure authenticated users can view the project creation screen.
|
* Ensure authenticated users can view the project creation screen.
|
||||||
*/
|
*/
|
||||||
@@ -86,6 +127,14 @@ class ProjectControllerTest extends TestCase
|
|||||||
'name' => 'Kanban Foundation',
|
'name' => 'Kanban Foundation',
|
||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
foreach (ProjectPermissionService::permissions() as $permission) {
|
||||||
|
$this->assertDatabaseHas('project_user_permissions', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'permission' => $permission,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,14 +185,147 @@ class ProjectControllerTest extends TestCase
|
|||||||
'name' => 'Core Platform',
|
'name' => 'Core Platform',
|
||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
]);
|
]);
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('projects.show', $project))
|
->get(route('projects.show', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('projects/Show')
|
->component('projects/Show')
|
||||||
|
->where('canManageAccess', false)
|
||||||
->where('project.id', $project->id)
|
->where('project.id', $project->id)
|
||||||
->where('project.key', 'CORE'),
|
->where('project.key', 'CORE'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure team-inherited permissions allow users to open a project.
|
||||||
|
*/
|
||||||
|
public function test_team_permissions_allow_users_to_view_a_project(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'TEAM',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$team->users()->attach($user);
|
||||||
|
app(ProjectPermissionService::class)->syncTeamPermissions($project, $team, ['view']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('projects.show', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Show')
|
||||||
|
->where('project.id', $project->id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure users without direct or inherited access are forbidden.
|
||||||
|
*/
|
||||||
|
public function test_users_without_permission_cannot_view_a_project(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('projects.show', $project))
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure edit access allows opening the project access management page.
|
||||||
|
*/
|
||||||
|
public function test_users_with_edit_permission_can_view_project_access_management(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
$team = Team::factory()->create([
|
||||||
|
'name' => 'Platform Team',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('projects.access', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Access')
|
||||||
|
->where('project.id', $project->id)
|
||||||
|
->has('users', 1)
|
||||||
|
->has('teams', 1)
|
||||||
|
->where('permissionOptions.0.value', 'create'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure admins can update direct project permissions for a user.
|
||||||
|
*/
|
||||||
|
public function test_admins_can_update_direct_project_permissions(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$member = User::factory()->create([
|
||||||
|
'name' => 'Regular User',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'AUTH',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('projects.permissions.users.update', [$project, $member]), [
|
||||||
|
'permissions' => ['view', 'edit'],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.access', $project))
|
||||||
|
->assertSessionHas('status', 'Regular User permissions were updated.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_user_permissions', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'user_id' => $member->id,
|
||||||
|
'permission' => 'view',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_user_permissions', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'user_id' => $member->id,
|
||||||
|
'permission' => 'edit',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure users with project edit access can update team permissions.
|
||||||
|
*/
|
||||||
|
public function test_users_with_edit_permission_can_update_team_permissions(): void
|
||||||
|
{
|
||||||
|
$manager = User::factory()->create();
|
||||||
|
$team = Team::factory()->create([
|
||||||
|
'name' => 'Delivery Team',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'OPS',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $manager, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($manager)
|
||||||
|
->patch(route('projects.permissions.teams.update', [$project, $team]), [
|
||||||
|
'permissions' => ['view', 'create'],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.access', $project))
|
||||||
|
->assertSessionHas('status', 'Delivery Team permissions were updated.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_team_permissions', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_id' => $team->id,
|
||||||
|
'permission' => 'view',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_team_permissions', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_id' => $team->id,
|
||||||
|
'permission' => 'create',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Teams;
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TeamControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure admins can view the teams management page.
|
||||||
|
*/
|
||||||
|
public function test_admins_can_view_the_teams_management_page(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$member = User::factory()->create([
|
||||||
|
'name' => 'Regular User',
|
||||||
|
'email' => 'member@example.com',
|
||||||
|
]);
|
||||||
|
$team = Team::factory()->create([
|
||||||
|
'name' => 'Platform Team',
|
||||||
|
]);
|
||||||
|
$team->users()->attach($member);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.teams.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('teams/Index')
|
||||||
|
->where('teams.0.name', 'Platform Team')
|
||||||
|
->where('teams.0.user_ids.0', $member->id)
|
||||||
|
->where('teams.0.users.0.email', 'member@example.com'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure admins can create teams.
|
||||||
|
*/
|
||||||
|
public function test_admins_can_create_teams(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post(route('admin.teams.store'), [
|
||||||
|
'name' => 'Delivery Team',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.teams.index'))
|
||||||
|
->assertSessionHas('status', 'Delivery Team was created.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('teams', [
|
||||||
|
'name' => 'Delivery Team',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure admins can update team memberships.
|
||||||
|
*/
|
||||||
|
public function test_admins_can_update_team_memberships(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$firstMember = User::factory()->create();
|
||||||
|
$secondMember = User::factory()->create();
|
||||||
|
$team = Team::factory()->create([
|
||||||
|
'name' => 'Platform Team',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('admin.teams.members.update', $team), [
|
||||||
|
'user_ids' => [$firstMember->id, $secondMember->id],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.teams.index'))
|
||||||
|
->assertSessionHas('status', 'Platform Team members were updated.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('team_user', [
|
||||||
|
'team_id' => $team->id,
|
||||||
|
'user_id' => $firstMember->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('team_user', [
|
||||||
|
'team_id' => $team->id,
|
||||||
|
'user_id' => $secondMember->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure non-admin users cannot view the teams management page.
|
||||||
|
*/
|
||||||
|
public function test_non_admins_cannot_view_the_teams_management_page(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.teams.index'))
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure non-admin users cannot create teams.
|
||||||
|
*/
|
||||||
|
public function test_non_admins_cannot_create_teams(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('admin.teams.store'), [
|
||||||
|
'name' => 'Platform Team',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure non-admin users cannot update team memberships.
|
||||||
|
*/
|
||||||
|
public function test_non_admins_cannot_update_team_memberships(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('admin.teams.members.update', $team), [
|
||||||
|
'user_ids' => [],
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
Nur check ob authorized? Nicht admin?
in dem controller wird gecheckt, ob der user die richtigen rechte hat. würde das perm system aber eh noch ändenr, deswegen egal