Merge pull request 'Implement project memberships teams and permission model' (#6) from codex/0.1-kanban-foundation/03-project-memberships-teams-and-permission-model into main
linter / quality (push) Successful in 1m12s
tests / ci (8.4) (push) Successful in 1m14s

Reviewed-on: OpenSource/KanbanGit#6
This commit was merged in pull request #6.
This commit is contained in:
2026-03-26 19:54:42 +00:00
29 changed files with 1735 additions and 9 deletions
@@ -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();
}
}
+97 -4
View File
@@ -2,19 +2,42 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SyncProjectPermissionsRequest;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
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', [
'projects' => Project::query()
->orderByDesc('id')
'projects' => $projectQuery
->get(),
]);
}
@@ -27,14 +50,84 @@ class ProjectController extends Controller
public function store(StoreProjectRequest $request): RedirectResponse
{
$project = Project::query()->create($request->validated());
$this->projectPermissionService->grantAllProjectPermissions($project, $request->user());
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', [
'canManageAccess' => $this->projectPermissionService->userCanManageProjectAccess($request->user(), $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.");
}
}
+57
View File
@@ -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.");
}
}
+35
View File
@@ -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
{
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();
}
}
+22
View File
@@ -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();
}
}
+7
View File
@@ -7,6 +7,7 @@ use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
@@ -32,4 +33,10 @@ class User extends Authenticatable
'two_factor_confirmed_at' => 'datetime',
];
}
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)
->withTimestamps();
}
}
+24
View File
@@ -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();
$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');
}
};
+3 -1
View File
@@ -13,8 +13,10 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->call([
ProjectSeeder::class,
UserSeeder::class,
ProjectSeeder::class,
TeamSeeder::class,
ProjectAccessSeeder::class,
]);
}
}
+42
View File
@@ -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',
]);
}
}
}
+31
View File
@@ -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());
}
}
+12 -1
View File
@@ -2,5 +2,16 @@
### Ticket
- general:
- new skill, user resources
- 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
+9 -1
View File
@@ -6,6 +6,7 @@ import {
FolderGit2,
LayoutGrid,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
@@ -21,9 +22,10 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as adminTeams } from '@/routes/admin/teams';
import { index as adminUsers } from '@/routes/admin/users';
import { index as projectsIndex } from '@/routes/projects';
import { dashboard } from '@/routes';
import type { NavItem } from '@/types';
const page = usePage();
@@ -48,6 +50,12 @@ const mainNavItems = computed<NavItem[]>(() => {
href: adminUsers(),
icon: Shield,
});
items.push({
title: 'Teams',
href: adminTeams(),
icon: Users,
});
}
return items;
@@ -98,6 +98,7 @@ watch(
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
}
+295
View File
@@ -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>
+7 -1
View File
@@ -12,7 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card';
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';
const props = defineProps<ProjectShowPageProps>();
@@ -73,6 +73,12 @@ const projectAreas = [
</Button>
</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">
<Card
v-for="area in projectAreas"
+179
View File
@@ -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>
+10
View File
@@ -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;
};
+23
View File
@@ -1,4 +1,5 @@
import type { ProjectModel } from '@/types/models/project-model';
import type { TeamModel } from '@/types/models/team-model';
import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = {
@@ -11,5 +12,27 @@ export type ProjectsIndexPageProps = {
};
export type ProjectShowPageProps = {
canManageAccess: boolean;
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[];
};
+12
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\Admin\UserManagementController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\TeamController;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
@@ -14,6 +15,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('projects', ProjectController::class)
->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'])
@@ -22,6 +30,10 @@ Route::middleware(['auth', 'verified', 'can:access-admin'])
->group(function () {
Route::get('users', [UserManagementController::class, 'index'])->name('users.index');
Route::patch('users/{user}/admin-status', [UserManagementController::class, 'update'])->name('users.update');
Route::get('teams', [TeamController::class, 'index'])->name('teams.index');
Route::post('teams', [TeamController::class, 'store'])->name('teams.store');
Route::patch('teams/{team}/members', [TeamController::class, 'updateMembers'])->name('teams.members.update');
});
require __DIR__.'/settings.php';
+85
View File
@@ -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',
]);
}
}
+14
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature\Models;
use App\Models\Team;
use App\Models\User;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -48,4 +49,17 @@ class UserTest extends TestCase
'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;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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
{
$user = User::factory()->create();
$user = User::factory()->admin()->create();
$firstProject = Project::factory()->create([
'name' => 'Core Platform',
'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.
*/
@@ -86,6 +127,14 @@ class ProjectControllerTest extends TestCase
'name' => 'Kanban Foundation',
'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',
'key' => 'CORE',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.show', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Show')
->where('canManageAccess', false)
->where('project.id', $project->id)
->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',
]);
}
}
+130
View File
@@ -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();
}
}