diff --git a/app/Actions/ProjectPermissions/ProjectPermissionService.php b/app/Actions/ProjectPermissions/ProjectPermissionService.php new file mode 100644 index 0000000..87ba664 --- /dev/null +++ b/app/Actions/ProjectPermissions/ProjectPermissionService.php @@ -0,0 +1,247 @@ + + */ + public const PERMISSIONS = [ + 'create', + 'view', + 'edit', + 'delete', + ]; + + /** + * @return array + */ + public static function permissions(): array + { + return self::PERMISSIONS; + } + + /** + * @return array + */ + 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 $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 $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 $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 + */ + 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> + */ + public function userPermissionMap(Project $project): array + { + /** @var Collection $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> + */ + public function teamPermissionMap(Project $project): array + { + /** @var Collection $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 $permissions + * @return array + */ + 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(); + } +} diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index a9998ed..79d9157 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -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."); + } } diff --git a/app/Http/Controllers/TeamController.php b/app/Http/Controllers/TeamController.php new file mode 100644 index 0000000..fa32843 --- /dev/null +++ b/app/Http/Controllers/TeamController.php @@ -0,0 +1,57 @@ + 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."); + } +} diff --git a/app/Http/Requests/StoreTeamRequest.php b/app/Http/Requests/StoreTeamRequest.php new file mode 100644 index 0000000..3cfb859 --- /dev/null +++ b/app/Http/Requests/StoreTeamRequest.php @@ -0,0 +1,35 @@ +user()?->can('access-admin') ?? false; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('teams', 'name'), + ], + ]; + } +} diff --git a/app/Http/Requests/SyncProjectPermissionsRequest.php b/app/Http/Requests/SyncProjectPermissionsRequest.php new file mode 100644 index 0000000..7dfa672 --- /dev/null +++ b/app/Http/Requests/SyncProjectPermissionsRequest.php @@ -0,0 +1,47 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'permissions' => ['nullable', 'array'], + 'permissions.*' => [ + 'string', + Rule::in(ProjectPermissionService::permissions()), + ], + ]; + } + + /** + * @return array + */ + public function permissions(): array + { + return collect($this->validated('permissions', [])) + ->map(fn (mixed $permission): string => (string) $permission) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Http/Requests/UpdateTeamMembersRequest.php b/app/Http/Requests/UpdateTeamMembersRequest.php new file mode 100644 index 0000000..af3106d --- /dev/null +++ b/app/Http/Requests/UpdateTeamMembersRequest.php @@ -0,0 +1,43 @@ +user()?->can('access-admin') ?? false; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'user_ids' => ['nullable', 'array'], + 'user_ids.*' => ['integer', Rule::exists('users', 'id')], + ]; + } + + /** + * @return array + */ + public function userIds(): array + { + return collect($this->validated('user_ids', [])) + ->map(fn (mixed $userId): int => (int) $userId) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php new file mode 100644 index 0000000..18a1d08 --- /dev/null +++ b/app/Models/Team.php @@ -0,0 +1,22 @@ + */ + use HasFactory; + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->withTimestamps(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6398fa1..39ed97e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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(); + } } diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php new file mode 100644 index 0000000..57cf648 --- /dev/null +++ b/database/factories/TeamFactory.php @@ -0,0 +1,24 @@ + + */ +class TeamFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->company().' Team', + ]; + } +} diff --git a/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php b/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php new file mode 100644 index 0000000..f793b0f --- /dev/null +++ b/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php b/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php new file mode 100644 index 0000000..d781549 --- /dev/null +++ b/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_24_234735_create_team_user_table.php b/database/migrations/2026_03_24_234735_create_team_user_table.php new file mode 100644 index 0000000..be9eb54 --- /dev/null +++ b/database/migrations/2026_03_24_234735_create_team_user_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_24_234735_create_teams_table.php b/database/migrations/2026_03_24_234735_create_teams_table.php new file mode 100644 index 0000000..bb1f825 --- /dev/null +++ b/database/migrations/2026_03_24_234735_create_teams_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('teams'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index db62723..f92aaf9 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,8 +13,10 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ - ProjectSeeder::class, UserSeeder::class, + ProjectSeeder::class, + TeamSeeder::class, + ProjectAccessSeeder::class, ]); } } diff --git a/database/seeders/ProjectAccessSeeder.php b/database/seeders/ProjectAccessSeeder.php new file mode 100644 index 0000000..339a657 --- /dev/null +++ b/database/seeders/ProjectAccessSeeder.php @@ -0,0 +1,42 @@ +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', + ]); + } + } +} diff --git a/database/seeders/TeamSeeder.php b/database/seeders/TeamSeeder.php new file mode 100644 index 0000000..d1b0eea --- /dev/null +++ b/database/seeders/TeamSeeder.php @@ -0,0 +1,31 @@ +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()); + } +} diff --git a/docs/missing-features.md b/docs/missing-features.md index cb617eb..cd0946d 100644 --- a/docs/missing-features.md +++ b/docs/missing-features.md @@ -2,5 +2,16 @@ ### Ticket +- general: + - new skill, user resources + - 02: - - Delete Projekt as Admin \ No newline at end of file + - 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 diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 801ad56..5c8302e 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -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(() => { href: adminUsers(), icon: Shield, }); + + items.push({ + title: 'Teams', + href: adminTeams(), + icon: Users, + }); } return items; diff --git a/resources/js/components/TwoFactorSetupModal.vue b/resources/js/components/TwoFactorSetupModal.vue index 6e6d96a..c69dace 100644 --- a/resources/js/components/TwoFactorSetupModal.vue +++ b/resources/js/components/TwoFactorSetupModal.vue @@ -98,6 +98,7 @@ watch( async (isOpen) => { if (!isOpen) { resetModalState(); + return; } diff --git a/resources/js/pages/projects/Access.vue b/resources/js/pages/projects/Access.vue new file mode 100644 index 0000000..c08d46e --- /dev/null +++ b/resources/js/pages/projects/Access.vue @@ -0,0 +1,295 @@ + + + diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index 4c17fe0..62cacbf 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -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(); @@ -73,6 +73,12 @@ const projectAreas = [ +
+ +
+
+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(); + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Teams', + href: teamsIndex(), + }, +]; + + + diff --git a/resources/js/types/models/team-model.ts b/resources/js/types/models/team-model.ts new file mode 100644 index 0000000..928a58a --- /dev/null +++ b/resources/js/types/models/team-model.ts @@ -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; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index 3e62aea..6b82106 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -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; + permissionOptions: ProjectPermissionOption[]; + project: ProjectModel; + status?: string | null; + teamPermissions: Record; + teams: TeamModel[]; + users: UserModel[]; +}; + +export type TeamsIndexPageProps = { + status?: string | null; + teams: TeamModel[]; + users: UserModel[]; +}; diff --git a/routes/web.php b/routes/web.php index c353e77..9a95f6a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'; diff --git a/tests/Feature/Models/TeamTest.php b/tests/Feature/Models/TeamTest.php new file mode 100644 index 0000000..568b375 --- /dev/null +++ b/tests/Feature/Models/TeamTest.php @@ -0,0 +1,85 @@ +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', + ]); + } +} diff --git a/tests/Feature/Models/UserTest.php b/tests/Feature/Models/UserTest.php index d6d0eb8..a9eb5ee 100644 --- a/tests/Feature/Models/UserTest.php +++ b/tests/Feature/Models/UserTest.php @@ -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); + } } diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index 686156a..97e55d3 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -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', + ]); + } } diff --git a/tests/Feature/Teams/TeamControllerTest.php b/tests/Feature/Teams/TeamControllerTest.php new file mode 100644 index 0000000..f94a32c --- /dev/null +++ b/tests/Feature/Teams/TeamControllerTest.php @@ -0,0 +1,130 @@ +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(); + } +}