Implement project memberships teams and permission model #6

Merged
Twyco merged 4 commits from codex/0.1-kanban-foundation/03-project-memberships-teams-and-permission-model into main 2026-03-26 19:54:43 +00:00
27 changed files with 1722 additions and 7 deletions
Showing only changes of commit 840d01f6fc - Show all commits
@@ -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
Twyco marked this conversation as resolved
Review

Nur check ob authorized? Nicht admin?

Nur check ob authorized? Nicht admin?
Review
image.png

in dem controller wird gecheckt, ob der user die richtigen rechte hat. würde das perm system aber eh noch ändenr, deswegen egal

<img width="881" alt="image.png" src="attachments/cbff6e43-ad97-40b9-b509-5788305f72e0"> in dem controller wird gecheckt, ob der user die richtigen rechte hat. würde das perm system aber eh noch ändenr, deswegen egal
350 KiB
{
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();
Twyco marked this conversation as resolved
Review

was macht withTimeStamps also warum nötig

was macht withTimeStamps also warum nötig
Review

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
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();
Review

@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');
}
};
+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());
}
}
+8
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,6 +22,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
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';
@@ -48,6 +50,12 @@ const mainNavItems = computed<NavItem[]>(() => {
href: adminUsers(),
icon: Shield,
});
items.push({
title: 'Teams',
href: adminTeams(),
icon: Users,
});
}
return items;
+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, Users } 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>
+11
View File
@@ -0,0 +1,11 @@
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;
[key: string]: unknown;
Twyco marked this conversation as resolved
Review

Was diese lan

Was diese lan
};
+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();
}
}