-
-
- {{
- user.is_admin ? 'Admin' : 'User'
- }}
-
+
+
+
+ Global admin
+
+
+
+ Project create
+
+
+ No direct global permissions
+
+
@@ -124,33 +155,40 @@ const breadcrumbs: BreadcrumbItem[] = [
user.id,
)
"
+ class="flex flex-wrap justify-end gap-3"
v-slot="{ processing }"
>
-
+
+
+ {{
+ option.label
+ }}
+
+
+
- {{
- user.is_admin
- ? 'Remove admin'
- : 'Make admin'
- }}
+ Save
diff --git a/resources/js/pages/projects/Access.vue b/resources/js/pages/projects/Access.vue
index 69864fc..fb7b412 100644
--- a/resources/js/pages/projects/Access.vue
+++ b/resources/js/pages/projects/Access.vue
@@ -60,7 +60,7 @@ const hasPermission = (
diff --git a/resources/js/pages/projects/Index.vue b/resources/js/pages/projects/Index.vue
index 126ff83..213f17c 100644
--- a/resources/js/pages/projects/Index.vue
+++ b/resources/js/pages/projects/Index.vue
@@ -38,7 +38,10 @@ const breadcrumbs: BreadcrumbItem[] = [
description="Create and browse project spaces that will later host boards, backlog, and settings."
/>
-
+
New project
diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue
index ea8e1e3..1591081 100644
--- a/resources/js/pages/projects/Show.vue
+++ b/resources/js/pages/projects/Show.vue
@@ -107,7 +107,7 @@ const projectAreas = [
diff --git a/resources/js/pages/projects/settings/Board.vue b/resources/js/pages/projects/settings/Board.vue
index 03b7095..272cbce 100644
--- a/resources/js/pages/projects/settings/Board.vue
+++ b/resources/js/pages/projects/settings/Board.vue
@@ -61,7 +61,7 @@ const breadcrumbs: BreadcrumbItem[] = [
diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue
index 4d240b4..3ae452a 100644
--- a/resources/js/pages/projects/settings/General.vue
+++ b/resources/js/pages/projects/settings/General.vue
@@ -22,10 +22,7 @@ import {
metadata as metadataSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
-import type {
- BreadcrumbItem,
- ProjectGeneralSettingsPageProps,
-} from '@/types';
+import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
import { computed } from 'vue';
const props = defineProps();
@@ -124,24 +121,24 @@ const submitDelete = (): void => {
-
+
Project name
-
+
{{ project.name }}
-
+
Project key
-
+
{{ project.key }}
@@ -166,9 +163,9 @@ const submitDelete = (): void => {
-
+
Confirm by typing the exact project key:
{{ project.key }}
@@ -193,7 +190,7 @@ const submitDelete = (): void => {
-
+
This action cannot be undone.
diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue
index 483f9e7..4007c43 100644
--- a/resources/js/pages/projects/settings/Metadata.vue
+++ b/resources/js/pages/projects/settings/Metadata.vue
@@ -65,7 +65,7 @@ const breadcrumbs: BreadcrumbItem[] = [
diff --git a/resources/js/pages/projects/settings/Repositories.vue b/resources/js/pages/projects/settings/Repositories.vue
index 1bee260..114a86c 100644
--- a/resources/js/pages/projects/settings/Repositories.vue
+++ b/resources/js/pages/projects/settings/Repositories.vue
@@ -234,7 +234,7 @@ const submitUpdate = (): void => {
diff --git a/resources/js/pages/teams/Index.vue b/resources/js/pages/teams/Index.vue
index e682eaa..d14385f 100644
--- a/resources/js/pages/teams/Index.vue
+++ b/resources/js/pages/teams/Index.vue
@@ -16,10 +16,10 @@ import {
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 { index as teamsIndex } from '@/routes/teams';
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
-defineProps();
+const props = defineProps();
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -27,6 +27,15 @@ const breadcrumbs: BreadcrumbItem[] = [
href: teamsIndex(),
},
];
+
+const hasPermission = (
+ permissions: Record,
+ actorId: number,
+ permission: string,
+) => (permissions[actorId] ?? []).includes(permission);
+
+const permissionsForTeam = (teamId: number): Record =>
+ props.teamPermissions[teamId] ?? {};
@@ -39,12 +48,12 @@ const breadcrumbs: BreadcrumbItem[] = [
>
- Admin-only area
+ {{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
@@ -55,8 +64,15 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ status }}
-
-
+
+
Create team
@@ -121,7 +137,7 @@ const breadcrumbs: BreadcrumbItem[] = [
-
+
- No teams exist yet.
+ No teams are available for your current permissions.
diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts
index f1294b6..0a1e9e2 100644
--- a/resources/js/types/auth.ts
+++ b/resources/js/types/auth.ts
@@ -2,7 +2,14 @@ import type { UserModel } from '@/types/models/user-model';
export type User = UserModel;
+export type AuthPermissions = {
+ canManageTeams: boolean;
+ globalAdmin: boolean;
+ projectCreate: boolean;
+};
+
export type Auth = {
+ permissions: AuthPermissions;
user: User;
};
diff --git a/resources/js/types/models/user-model.ts b/resources/js/types/models/user-model.ts
index 7b8c503..1eeef1e 100644
--- a/resources/js/types/models/user-model.ts
+++ b/resources/js/types/models/user-model.ts
@@ -4,7 +4,6 @@ export type UserModel = {
email: string;
avatar?: string;
email_verified_at: string | null;
- is_admin: boolean;
created_at: string;
updated_at: string;
};
diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts
index fe1cf7d..3fc7fae 100644
--- a/resources/js/types/page-props.ts
+++ b/resources/js/types/page-props.ts
@@ -17,6 +17,8 @@ import type { UserGitConnectionModel } from '@/types/models/user-git-connection-
import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = {
+ directPermissions: Record
;
+ permissionOptions: PermissionOption[];
users: UserModel[];
status?: string | null;
};
@@ -31,14 +33,14 @@ export type ProjectShowPageProps = {
project: ProjectModel;
};
-export type ProjectPermissionOption = {
+export type PermissionOption = {
label: string;
value: string;
};
export type ProjectAccessPageProps = {
directPermissions: Record;
- permissionOptions: ProjectPermissionOption[];
+ permissionOptions: PermissionOption[];
project: ProjectModel;
status?: string | null;
teamPermissions: Record;
@@ -47,7 +49,10 @@ export type ProjectAccessPageProps = {
};
export type TeamsIndexPageProps = {
+ canCreateTeams: boolean;
+ permissionOptions: PermissionOption[];
status?: string | null;
+ teamPermissions: Record>;
teams: TeamModel[];
users: UserModel[];
};
diff --git a/routes/web.php b/routes/web.php
index 2b04507..078ebe4 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -103,6 +103,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
->name('projects.settings.metadata.link-types.destroy');
});
+
+ Route::get('teams', [TeamController::class, 'index'])
+ ->middleware('can:view-teams')
+ ->name('teams.index');
+ Route::post('teams', [TeamController::class, 'store'])
+ ->middleware('can:create-team')
+ ->name('teams.store');
+ Route::patch('teams/{team}/members', [TeamController::class, 'updateMembers'])
+ ->name('teams.members.update');
+ Route::patch('teams/{team}/permissions/users/{user}', [TeamController::class, 'updateUserPermissions'])
+ ->name('teams.permissions.users.update');
});
Route::middleware(['auth', 'verified', 'can:access-admin'])
@@ -110,11 +121,7 @@ Route::middleware(['auth', 'verified', 'can:access-admin'])
->name('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');
+ Route::patch('users/{user}/permissions', [UserManagementController::class, 'update'])->name('users.update');
});
require __DIR__.'/settings.php';
diff --git a/tests/Feature/Admin/UserManagementControllerTest.php b/tests/Feature/Admin/UserManagementControllerTest.php
index b38176c..77c7e99 100644
--- a/tests/Feature/Admin/UserManagementControllerTest.php
+++ b/tests/Feature/Admin/UserManagementControllerTest.php
@@ -27,10 +27,10 @@ class UserManagementControllerTest extends TestCase
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('admin/users/Index')
- ->where('users.0.email', $admin->email)
- ->where('users.1.email', $member->email)
- ->where('users.0.is_admin', true)
- ->where('users.1.is_admin', false),
+ ->has('users', 2)
+ ->where("directPermissions.{$admin->id}.0", 'global:admin')
+ ->where('permissionOptions.0.value', 'global:admin')
+ ->where('permissionOptions.1.value', 'project:create'),
);
}
@@ -47,9 +47,9 @@ class UserManagementControllerTest extends TestCase
}
/**
- * Ensure admins can update another user's admin access.
+ * Ensure admins can update another user's global permissions.
*/
- public function test_admin_can_update_a_users_admin_access(): void
+ public function test_admin_can_update_a_users_global_permissions(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create([
@@ -58,30 +58,72 @@ class UserManagementControllerTest extends TestCase
$this->actingAs($admin)
->patch(route('admin.users.update', $member), [
- 'is_admin' => true,
+ 'permissions' => ['global:admin', 'project:create'],
])
->assertRedirect(route('admin.users.index'))
- ->assertSessionHas('status', 'Regular User now has admin access.');
+ ->assertSessionHas('status', 'Regular User global permissions were updated.');
- $this->assertTrue($member->refresh()->is_admin);
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'permission' => 'global:admin',
+ 'scope_id' => null,
+ 'scope_type' => null,
+ ]);
+
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'permission' => 'project:create',
+ 'scope_id' => null,
+ 'scope_type' => null,
+ ]);
}
/**
- * Ensure non-admin users cannot update admin access.
+ * Ensure admins can also clear a user's global permissions.
*/
- public function test_non_admin_cannot_update_a_users_admin_access(): void
+ public function test_admin_can_clear_a_users_global_permissions(): void
+ {
+ $admin = User::factory()->admin()->create();
+ $member = User::factory()->create();
+ $this->syncGlobalPermissions($member, ['global:admin', 'project:create']);
+
+ $this->actingAs($admin)
+ ->patch(route('admin.users.update', $member), [
+ 'permissions' => [],
+ ])
+ ->assertRedirect(route('admin.users.index'))
+ ->assertSessionHas('status', "{$member->name} global permissions were updated.");
+
+ $this->assertDatabaseMissing('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'scope_id' => null,
+ 'scope_type' => null,
+ ]);
+ }
+
+ /**
+ * Ensure non-admin users cannot update global permissions.
+ */
+ public function test_non_admin_cannot_update_a_users_global_permissions(): void
{
$member = User::factory()->create();
- $otherUser = User::factory()->create([
- 'is_admin' => false,
- ]);
+ $otherUser = User::factory()->create();
$this->actingAs($member)
->patch(route('admin.users.update', $otherUser), [
- 'is_admin' => true,
+ 'permissions' => ['global:admin'],
])
->assertForbidden();
- $this->assertFalse($otherUser->refresh()->is_admin);
+ $this->assertDatabaseMissing('permission_assignments', [
+ 'assignee_id' => $otherUser->id,
+ 'assignee_type' => $otherUser->getMorphClass(),
+ 'permission' => 'global:admin',
+ 'scope_id' => null,
+ 'scope_type' => null,
+ ]);
}
}
diff --git a/tests/Feature/Models/PermissionAssignmentTest.php b/tests/Feature/Models/PermissionAssignmentTest.php
new file mode 100644
index 0000000..4a61a2f
--- /dev/null
+++ b/tests/Feature/Models/PermissionAssignmentTest.php
@@ -0,0 +1,66 @@
+create();
+
+ $this->assertInstanceOf(User::class, $assignment->assignee);
+ $this->assertInstanceOf(Project::class, $assignment->scope);
+ $this->assertSame('project:read', $assignment->permission->value);
+ }
+
+ /**
+ * Ensure deleting users, teams, and projects removes related permission assignments.
+ */
+ public function test_related_permission_assignments_are_removed_when_parent_models_are_deleted(): void
+ {
+ $user = User::factory()->create();
+ $team = Team::factory()->create();
+ $project = Project::factory()->create();
+
+ $userAssignment = PermissionAssignment::factory()
+ ->forAssignee($user)
+ ->globalAdmin()
+ ->create();
+ $teamAssignment = PermissionAssignment::factory()
+ ->forAssignee($team)
+ ->forScope($project)
+ ->projectWrite()
+ ->create();
+ $teamScopeAssignment = PermissionAssignment::factory()
+ ->forAssignee($user)
+ ->forScope($team)
+ ->teamManage()
+ ->create();
+
+ $user->delete();
+ $project->delete();
+ $team->delete();
+
+ $this->assertDatabaseMissing('permission_assignments', [
+ 'id' => $userAssignment->id,
+ ]);
+ $this->assertDatabaseMissing('permission_assignments', [
+ 'id' => $teamAssignment->id,
+ ]);
+ $this->assertDatabaseMissing('permission_assignments', [
+ 'id' => $teamScopeAssignment->id,
+ ]);
+ }
+}
diff --git a/tests/Feature/Models/TeamTest.php b/tests/Feature/Models/TeamTest.php
index 568b375..8a5d0d4 100644
--- a/tests/Feature/Models/TeamTest.php
+++ b/tests/Feature/Models/TeamTest.php
@@ -66,20 +66,24 @@ class TeamTest extends TestCase
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();
+ $testUser = User::query()->where('email', 'test@example.com')->firstOrFail();
- $this->assertDatabaseHas('project_user_permissions', [
- 'project_id' => $coreProject->id,
- 'user_id' => $admin->id,
- 'permission' => 'view',
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $platformTeam->id,
+ 'assignee_type' => $platformTeam->getMorphClass(),
+ 'permission' => 'project:write',
+ 'scope_id' => $coreProject->id,
+ 'scope_type' => $coreProject->getMorphClass(),
]);
- $this->assertDatabaseHas('project_team_permissions', [
- 'project_id' => $coreProject->id,
- 'team_id' => $platformTeam->id,
- 'permission' => 'edit',
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $testUser->id,
+ 'assignee_type' => $testUser->getMorphClass(),
+ 'permission' => 'team:manage',
+ 'scope_id' => $platformTeam->id,
+ 'scope_type' => $platformTeam->getMorphClass(),
]);
}
}
diff --git a/tests/Feature/Models/UserTest.php b/tests/Feature/Models/UserTest.php
index a9eb5ee..b74f6db 100644
--- a/tests/Feature/Models/UserTest.php
+++ b/tests/Feature/Models/UserTest.php
@@ -13,13 +13,19 @@ class UserTest extends TestCase
use RefreshDatabase;
/**
- * Ensure the default user factory creates non-admin users.
+ * Ensure the default user factory does not grant global admin access.
*/
- public function test_user_factory_defaults_to_non_admin(): void
+ public function test_user_factory_defaults_to_no_global_admin_access(): void
{
$user = User::factory()->create();
- $this->assertFalse($user->is_admin);
+ $this->assertDatabaseMissing('permission_assignments', [
+ 'assignee_id' => $user->id,
+ 'assignee_type' => $user->getMorphClass(),
+ 'permission' => 'global:admin',
+ 'scope_id' => null,
+ 'scope_type' => null,
+ ]);
}
/**
@@ -29,7 +35,13 @@ class UserTest extends TestCase
{
$user = User::factory()->admin()->create();
- $this->assertTrue($user->is_admin);
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $user->id,
+ 'assignee_type' => $user->getMorphClass(),
+ 'permission' => 'global:admin',
+ 'scope_id' => null,
+ 'scope_type' => null,
+ ]);
}
/**
@@ -39,14 +51,23 @@ class UserTest extends TestCase
{
$this->seed(UserSeeder::class);
- $this->assertDatabaseHas('users', [
- 'email' => 'admin@kanbangit.test',
- 'is_admin' => true,
+ $admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
+ $member = User::query()->where('email', 'test@example.com')->firstOrFail();
+
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $admin->id,
+ 'assignee_type' => $admin->getMorphClass(),
+ 'permission' => 'global:admin',
+ 'scope_id' => null,
+ 'scope_type' => null,
]);
- $this->assertDatabaseHas('users', [
- 'email' => 'test@example.com',
- 'is_admin' => false,
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'permission' => 'project:create',
+ 'scope_id' => null,
+ 'scope_type' => null,
]);
}
diff --git a/tests/Feature/Permissions/PermissionServiceTest.php b/tests/Feature/Permissions/PermissionServiceTest.php
new file mode 100644
index 0000000..6f4a509
--- /dev/null
+++ b/tests/Feature/Permissions/PermissionServiceTest.php
@@ -0,0 +1,119 @@
+create();
+ $project = Project::factory()->create();
+
+ $service->sync($user, $project, [Permission::ProjectWrite]);
+
+ $this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
+ $this->assertTrue($service->allows($user, Permission::ProjectWrite, $project));
+ $this->assertFalse($service->allows($user, Permission::ProjectAdmin, $project));
+ }
+
+ /**
+ * Ensure project permissions assigned to teams are inherited by team members.
+ */
+ public function test_team_project_permissions_are_inherited_by_members(): void
+ {
+ $service = app(PermissionService::class);
+ $user = User::factory()->create();
+ $team = Team::factory()->create();
+ $project = Project::factory()->create();
+
+ $team->users()->attach($user);
+ $service->sync($team, $project, [Permission::ProjectRead]);
+
+ $this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
+ }
+
+ /**
+ * Ensure user and team permissions are combined instead of one replacing the other.
+ */
+ public function test_direct_user_permissions_are_combined_with_inherited_team_permissions(): void
+ {
+ $service = app(PermissionService::class);
+ $user = User::factory()->create();
+ $team = Team::factory()->create();
+ $project = Project::factory()->create();
+
+ $team->users()->attach($user);
+ $service->sync($team, $project, [Permission::ProjectWrite]);
+ $service->sync($user, $project, [Permission::ProjectRead]);
+
+ $this->assertTrue($service->allows($user, Permission::ProjectWrite, $project));
+ $this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
+ }
+
+ /**
+ * Ensure team-scoped permissions are granted directly to users and do not come from membership alone.
+ */
+ public function test_team_permissions_only_apply_when_directly_assigned_to_the_user(): void
+ {
+ $service = app(PermissionService::class);
+ $manager = User::factory()->create();
+ $member = User::factory()->create();
+ $team = Team::factory()->create();
+
+ $team->users()->attach([$manager->id, $member->id]);
+ $service->sync($manager, $team, [Permission::TeamManage]);
+
+ $this->assertTrue($service->allows($manager, Permission::TeamManage, $team));
+ $this->assertFalse($service->allows($member, Permission::TeamManage, $team));
+ }
+
+ /**
+ * Ensure global admins can perform global, project, and team-scoped actions.
+ */
+ public function test_global_admin_overrides_every_scope(): void
+ {
+ $service = app(PermissionService::class);
+ $admin = User::factory()->create();
+ $project = Project::factory()->create();
+ $team = Team::factory()->create();
+
+ $service->sync($admin, null, [Permission::GlobalAdmin]);
+
+ $this->assertTrue($service->allows($admin, Permission::ProjectCreate));
+ $this->assertTrue($service->allows($admin, Permission::ProjectAdmin, $project));
+ $this->assertTrue($service->allows($admin, Permission::TeamManage, $team));
+ }
+
+ /**
+ * Ensure the service can surface accessible project ids and manageable team ids.
+ */
+ public function test_scope_lookup_helpers_return_expected_ids(): void
+ {
+ $service = app(PermissionService::class);
+ $user = User::factory()->create();
+ $team = Team::factory()->create();
+ $project = Project::factory()->create();
+
+ $team->users()->attach($user);
+ $service->sync($team, $project, [Permission::ProjectRead]);
+ $service->sync($user, $team, [Permission::TeamManage]);
+
+ $this->assertSame([$project->id], $service->viewableProjectIds($user));
+ $this->assertSame([$team->id], $service->manageableTeamIds($user));
+ $this->assertTrue($service->canManageAnyTeam($user));
+ }
+}
diff --git a/tests/Feature/Projects/ProjectBacklogControllerTest.php b/tests/Feature/Projects/ProjectBacklogControllerTest.php
index eba5e03..67ef190 100644
--- a/tests/Feature/Projects/ProjectBacklogControllerTest.php
+++ b/tests/Feature/Projects/ProjectBacklogControllerTest.php
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
@@ -47,7 +46,7 @@ class ProjectBacklogControllerTest extends TestCase
'title' => 'Seed backlog item',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
+ $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.backlog.index', $project))
@@ -77,7 +76,7 @@ class ProjectBacklogControllerTest extends TestCase
'position' => 1,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
+ $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->post(route('projects.backlog.store', $project), [
@@ -107,7 +106,7 @@ class ProjectBacklogControllerTest extends TestCase
'position' => 1,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['create', 'view']);
+ $this->syncProjectPermissions($project, $user, ['create', 'view']);
$payload = [
'title' => 'Same title',
diff --git a/tests/Feature/Projects/ProjectBoardControllerTest.php b/tests/Feature/Projects/ProjectBoardControllerTest.php
index 1f49ea0..b29ba83 100644
--- a/tests/Feature/Projects/ProjectBoardControllerTest.php
+++ b/tests/Feature/Projects/ProjectBoardControllerTest.php
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
@@ -63,7 +62,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
+ $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.board.index', $project))
@@ -113,7 +112,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Backlog ticket',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -172,7 +171,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -227,7 +226,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -280,7 +279,7 @@ class ProjectBoardControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->from(route('projects.backlog.index', $project))
diff --git a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php
index 91220fe..9e385b6 100644
--- a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php
+++ b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\User;
@@ -28,7 +27,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 1,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.board', $project))
@@ -63,7 +62,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'key' => 'CORE',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.board.columns.store', $project), [
@@ -99,7 +98,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 3,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->patch(route('projects.settings.board.columns.update', [$project, $thirdColumn]), [
@@ -139,7 +138,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 3,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn]))
diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php
index be8d0df..3000baa 100644
--- a/tests/Feature/Projects/ProjectControllerTest.php
+++ b/tests/Feature/Projects/ProjectControllerTest.php
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
@@ -80,9 +79,8 @@ class ProjectControllerTest extends TestCase
'key' => 'HIDE',
]);
- $projectPermissionService = app(ProjectPermissionService::class);
- $projectPermissionService->syncUserPermissions($directProject, $user, ['view']);
- $projectPermissionService->syncTeamPermissions($teamProject, $team, ['view']);
+ $this->syncProjectPermissions($directProject, $user, ['view']);
+ $this->syncProjectPermissions($teamProject, $team, ['view']);
$this->actingAs($user)
->get(route('projects.index'))
@@ -96,11 +94,12 @@ class ProjectControllerTest extends TestCase
}
/**
- * Ensure authenticated users can view the project creation screen.
+ * Ensure users with project create permission can view the project creation screen.
*/
public function test_authenticated_users_can_view_the_project_creation_screen(): void
{
$user = User::factory()->create();
+ $this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user)
->get(route('projects.create'))
@@ -111,11 +110,24 @@ class ProjectControllerTest extends TestCase
}
/**
- * Ensure project keys are normalized to uppercase on creation.
+ * Ensure users without project create permission cannot view the project creation screen.
+ */
+ public function test_users_without_project_create_permission_cannot_view_the_project_creation_screen(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->get(route('projects.create'))
+ ->assertForbidden();
+ }
+
+ /**
+ * Ensure users with project create permission can create projects with uppercase keys.
*/
public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void
{
$user = User::factory()->create();
+ $this->syncGlobalPermissions($user, ['project:create']);
$response = $this->actingAs($user)
->post(route('projects.store'), [
@@ -132,13 +144,13 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE',
]);
- foreach (ProjectPermissionService::permissions() as $permission) {
- $this->assertDatabaseHas('project_user_permissions', [
- 'project_id' => $project->id,
- 'user_id' => $user->id,
- 'permission' => $permission,
- ]);
- }
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $user->id,
+ 'assignee_type' => $user->getMorphClass(),
+ 'permission' => 'project:admin',
+ 'scope_id' => $project->id,
+ 'scope_type' => $project->getMorphClass(),
+ ]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
@@ -161,6 +173,7 @@ class ProjectControllerTest extends TestCase
public function test_project_keys_must_be_unique_case_insensitively(): void
{
$user = User::factory()->create();
+ $this->syncGlobalPermissions($user, ['project:create']);
Project::factory()->create([
'key' => 'AUTH',
@@ -182,6 +195,7 @@ class ProjectControllerTest extends TestCase
public function test_project_keys_may_not_exceed_five_characters(): void
{
$user = User::factory()->create();
+ $this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user)
->from(route('projects.create'))
@@ -203,7 +217,7 @@ class ProjectControllerTest extends TestCase
'name' => 'Core Platform',
'key' => 'CORE',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
+ $this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.show', $project))
@@ -228,7 +242,7 @@ class ProjectControllerTest extends TestCase
]);
$team->users()->attach($user);
- app(ProjectPermissionService::class)->syncTeamPermissions($project, $team, ['view']);
+ $this->syncProjectPermissions($project, $team, ['view']);
$this->actingAs($user)
->get(route('projects.show', $project))
@@ -253,9 +267,9 @@ class ProjectControllerTest extends TestCase
}
/**
- * Ensure edit access allows opening the project access management page.
+ * Ensure project admin access allows opening the project access management page.
*/
- public function test_users_with_edit_permission_can_view_project_access_management(): void
+ public function test_users_with_project_admin_permission_can_view_project_access_management(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
@@ -265,7 +279,7 @@ class ProjectControllerTest extends TestCase
'name' => 'Platform Team',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get(route('projects.access', $project))
@@ -275,7 +289,7 @@ class ProjectControllerTest extends TestCase
->where('project.id', $project->id)
->has('users', 1)
->has('teams', 1)
- ->where('permissionOptions.0.value', 'create'),
+ ->where('permissionOptions.0.value', 'project:read'),
);
}
@@ -309,7 +323,7 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']);
+ $this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get(route('projects.settings.general', $project))
@@ -348,7 +362,7 @@ class ProjectControllerTest extends TestCase
'title' => 'Delete me',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $member, ['view']);
+ $this->syncProjectPermissions($project, $member, ['view']);
$this->actingAs($admin)
->delete(route('projects.destroy', $project), [
@@ -366,10 +380,12 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseMissing('tickets', [
'id' => $ticket->id,
]);
- $this->assertDatabaseMissing('project_user_permissions', [
- 'project_id' => $project->id,
- 'user_id' => $member->id,
- 'permission' => 'view',
+ $this->assertDatabaseMissing('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'permission' => 'project:read',
+ 'scope_id' => $project->id,
+ 'scope_type' => $project->getMorphClass(),
]);
}
@@ -406,7 +422,7 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']);
+ $this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->delete(route('projects.destroy', $project), [
@@ -434,28 +450,32 @@ class ProjectControllerTest extends TestCase
$this->actingAs($admin)
->patch(route('projects.permissions.users.update', [$project, $member]), [
- 'permissions' => ['view', 'edit'],
+ 'permissions' => ['project:read', 'project:write'],
])
->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('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'permission' => 'project:read',
+ 'scope_id' => $project->id,
+ 'scope_type' => $project->getMorphClass(),
]);
- $this->assertDatabaseHas('project_user_permissions', [
- 'project_id' => $project->id,
- 'user_id' => $member->id,
- 'permission' => 'edit',
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'permission' => 'project:write',
+ 'scope_id' => $project->id,
+ 'scope_type' => $project->getMorphClass(),
]);
}
/**
- * Ensure users with project edit access can update team permissions.
+ * Ensure users with project admin access can update team permissions.
*/
- public function test_users_with_edit_permission_can_update_team_permissions(): void
+ public function test_users_with_project_admin_permission_can_update_team_permissions(): void
{
$manager = User::factory()->create();
$team = Team::factory()->create([
@@ -465,25 +485,29 @@ class ProjectControllerTest extends TestCase
'key' => 'OPS',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $manager, ['edit']);
+ $this->syncProjectPermissions($project, $manager, ['admin']);
$this->actingAs($manager)
->patch(route('projects.permissions.teams.update', [$project, $team]), [
- 'permissions' => ['view', 'create'],
+ 'permissions' => ['project:read', 'project:write'],
])
->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('permission_assignments', [
+ 'assignee_id' => $team->id,
+ 'assignee_type' => $team->getMorphClass(),
+ 'permission' => 'project:read',
+ 'scope_id' => $project->id,
+ 'scope_type' => $project->getMorphClass(),
]);
- $this->assertDatabaseHas('project_team_permissions', [
- 'project_id' => $project->id,
- 'team_id' => $team->id,
- 'permission' => 'create',
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $team->id,
+ 'assignee_type' => $team->getMorphClass(),
+ 'permission' => 'project:write',
+ 'scope_id' => $project->id,
+ 'scope_type' => $project->getMorphClass(),
]);
}
}
diff --git a/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php
index c7b534c..83b61e1 100644
--- a/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php
+++ b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php
@@ -3,7 +3,6 @@
namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\User;
@@ -37,7 +36,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.repositories', $project))
@@ -92,7 +91,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.repositories.store', $project), [
@@ -126,7 +125,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
$project = Project::factory()->create();
$foreignGitConnection = UserGitConnection::factory()->for($otherUser)->create();
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.repositories.store', $project), [
@@ -173,7 +172,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->patch(route('projects.settings.repositories.update', [$project, $repositoryConnection]), [
@@ -204,7 +203,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'repository_full_name' => 'core-team/kanbangit',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->delete(route('projects.settings.repositories.destroy', [$project, $repositoryConnection]))
diff --git a/tests/Feature/Projects/ProjectTicketBranchControllerTest.php b/tests/Feature/Projects/ProjectTicketBranchControllerTest.php
index f02edae..e36a748 100644
--- a/tests/Feature/Projects/ProjectTicketBranchControllerTest.php
+++ b/tests/Feature/Projects/ProjectTicketBranchControllerTest.php
@@ -3,7 +3,6 @@
namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\RepositoryConnection;
@@ -48,7 +47,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
@@ -92,7 +91,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
@@ -128,7 +127,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
@@ -188,7 +187,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.branches.store', [$project, $ticket]), [
diff --git a/tests/Feature/Projects/ProjectTicketControllerTest.php b/tests/Feature/Projects/ProjectTicketControllerTest.php
index c6ff7a7..f8b469d 100644
--- a/tests/Feature/Projects/ProjectTicketControllerTest.php
+++ b/tests/Feature/Projects/ProjectTicketControllerTest.php
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\RepositoryConnection;
@@ -86,7 +85,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_id' => $ticket->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
+ $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $ticket]))
@@ -196,7 +195,7 @@ class ProjectTicketControllerTest extends TestCase
'base_url' => 'https://git.example.test',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
+ $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $ticket]))
@@ -244,7 +243,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
+ $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -325,7 +324,7 @@ class ProjectTicketControllerTest extends TestCase
'value_user_id' => null,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -417,7 +416,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Child ticket',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $childTicket]))
@@ -474,7 +473,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Child ticket',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $childTicket]), [
@@ -563,7 +562,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Nested task',
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
+ $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $rootParent]))
@@ -596,7 +595,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $ticket]))
@@ -645,7 +644,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [
diff --git a/tests/Feature/Projects/ProjectTicketLinkControllerTest.php b/tests/Feature/Projects/ProjectTicketLinkControllerTest.php
index e1e09ac..482436f 100644
--- a/tests/Feature/Projects/ProjectTicketLinkControllerTest.php
+++ b/tests/Feature/Projects/ProjectTicketLinkControllerTest.php
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\Ticket;
@@ -53,7 +52,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_type_id' => $taskType->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $ticket]), [
@@ -118,7 +117,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_type_id' => $otherTaskType->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->from(route('projects.tickets.show', [$project, $ticket]))
@@ -180,7 +179,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_link_type_id' => $ticketLinkType->id,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
+ $this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink]))
diff --git a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php
index 1fd6dad..9b896ab 100644
--- a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php
+++ b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
-use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
@@ -33,7 +32,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.metadata', $project))
@@ -55,7 +54,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.types.store', $project), [
@@ -80,7 +79,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$taskType = TicketType::query()
->where('project_id', $project->id)
@@ -130,7 +129,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.priorities.store', $project), [
@@ -169,7 +168,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.fields.store', $project), [
@@ -203,7 +202,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.metadata', $project))
@@ -227,7 +226,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
- app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
+ $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.link-types.store', $project), [
diff --git a/tests/Feature/Teams/TeamControllerTest.php b/tests/Feature/Teams/TeamControllerTest.php
index f94a32c..8f1b568 100644
--- a/tests/Feature/Teams/TeamControllerTest.php
+++ b/tests/Feature/Teams/TeamControllerTest.php
@@ -28,10 +28,11 @@ class TeamControllerTest extends TestCase
$team->users()->attach($member);
$this->actingAs($admin)
- ->get(route('admin.teams.index'))
+ ->get(route('teams.index'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('teams/Index')
+ ->where('canCreateTeams', true)
->where('teams.0.name', 'Platform Team')
->where('teams.0.user_ids.0', $member->id)
->where('teams.0.users.0.email', 'member@example.com'),
@@ -46,10 +47,10 @@ class TeamControllerTest extends TestCase
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
- ->post(route('admin.teams.store'), [
+ ->post(route('teams.store'), [
'name' => 'Delivery Team',
])
- ->assertRedirect(route('admin.teams.index'))
+ ->assertRedirect(route('teams.index'))
->assertSessionHas('status', 'Delivery Team was created.');
$this->assertDatabaseHas('teams', [
@@ -70,10 +71,10 @@ class TeamControllerTest extends TestCase
]);
$this->actingAs($admin)
- ->patch(route('admin.teams.members.update', $team), [
+ ->patch(route('teams.members.update', $team), [
'user_ids' => [$firstMember->id, $secondMember->id],
])
- ->assertRedirect(route('admin.teams.index'))
+ ->assertRedirect(route('teams.index'))
->assertSessionHas('status', 'Platform Team members were updated.');
$this->assertDatabaseHas('team_user', [
@@ -88,14 +89,61 @@ class TeamControllerTest extends TestCase
}
/**
- * Ensure non-admin users cannot view the teams management page.
+ * Ensure admins can view the teams management page.
+ */
+ public function test_non_admins_with_team_create_permission_can_view_the_teams_management_page(): void
+ {
+ $createTeamUser = User::factory()->create();
+ $member = User::factory()->create([
+ 'name' => 'Regular User',
+ 'email' => 'member@example.com',
+ ]);
+ $team = Team::factory()->create([
+ 'name' => 'Platform Team',
+ ]);
+ $team->users()->attach($member);
+
+ $this->syncGlobalPermissions($createTeamUser, ['team:create']);
+
+ $this->actingAs($createTeamUser)
+ ->get(route('teams.index'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('teams/Index')
+ ->where('canCreateTeams', true)
+ );
+ }
+
+ /**
+ * Ensure admins can create teams.
+ */
+ public function test_non_admins_with_team_create_permission_can_create_teams(): void
+ {
+ $createTeamUser = User::factory()->create();
+
+ $this->syncGlobalPermissions($createTeamUser, ['team:create']);
+
+ $this->actingAs($createTeamUser)
+ ->post(route('teams.store'), [
+ 'name' => 'Delivery Team',
+ ])
+ ->assertRedirect(route('teams.index'))
+ ->assertSessionHas('status', 'Delivery Team was created.');
+
+ $this->assertDatabaseHas('teams', [
+ 'name' => 'Delivery Team',
+ ]);
+ }
+
+ /**
+ * Ensure users without team management access cannot view the teams 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'))
+ ->get(route('teams.index'))
->assertForbidden();
}
@@ -107,7 +155,7 @@ class TeamControllerTest extends TestCase
$user = User::factory()->create();
$this->actingAs($user)
- ->post(route('admin.teams.store'), [
+ ->post(route('teams.store'), [
'name' => 'Platform Team',
])
->assertForbidden();
@@ -122,9 +170,48 @@ class TeamControllerTest extends TestCase
$team = Team::factory()->create();
$this->actingAs($user)
- ->patch(route('admin.teams.members.update', $team), [
+ ->patch(route('teams.members.update', $team), [
'user_ids' => [],
])
->assertForbidden();
}
+
+ /**
+ * Ensure users with team manage access can view and update their assigned teams.
+ */
+ public function test_team_managers_can_view_and_update_their_team(): void
+ {
+ $manager = User::factory()->create();
+ $member = User::factory()->create();
+ $team = Team::factory()->create([
+ 'name' => 'Platform Team',
+ ]);
+
+ $this->syncTeamPermissions($team, $manager, ['team:manage']);
+
+ $this->actingAs($manager)
+ ->get(route('teams.index'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('teams/Index')
+ ->where('canCreateTeams', false)
+ ->where('teams.0.name', 'Platform Team')
+ ->where("teamPermissions.{$team->id}.{$manager->id}.0", 'team:manage'),
+ );
+
+ $this->actingAs($manager)
+ ->patch(route('teams.permissions.users.update', [$team, $member]), [
+ 'permissions' => ['team:invite'],
+ ])
+ ->assertRedirect(route('teams.index'))
+ ->assertSessionHas('status', "{$member->name} permissions on {$team->name} were updated.");
+
+ $this->assertDatabaseHas('permission_assignments', [
+ 'assignee_id' => $member->id,
+ 'assignee_type' => $member->getMorphClass(),
+ 'permission' => 'team:invite',
+ 'scope_id' => $team->id,
+ 'scope_type' => $team->getMorphClass(),
+ ]);
+ }
}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 3399054..8a7bbf6 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -2,6 +2,11 @@
namespace Tests;
+use App\Actions\Permissions\PermissionService;
+use App\Enums\Permission;
+use App\Models\Project;
+use App\Models\Team;
+use App\Models\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Laravel\Fortify\Features;
@@ -13,4 +18,79 @@ abstract class TestCase extends BaseTestCase
$this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
}
}
+
+ /**
+ * @param array $permissions
+ */
+ protected function syncGlobalPermissions(User $user, array $permissions): void
+ {
+ app(PermissionService::class)->sync(
+ $user,
+ null,
+ $this->normalizePermissions($permissions, 'global'),
+ );
+ }
+
+ /**
+ * @param array $permissions
+ */
+ protected function syncProjectPermissions(Project $project, User|Team $assignee, array $permissions): void
+ {
+ app(PermissionService::class)->sync(
+ $assignee,
+ $project,
+ $this->normalizePermissions($permissions, 'project'),
+ );
+ }
+
+ /**
+ * @param array $permissions
+ */
+ protected function syncTeamPermissions(Team $team, User $user, array $permissions): void
+ {
+ app(PermissionService::class)->sync(
+ $user,
+ $team,
+ $this->normalizePermissions($permissions, 'team'),
+ );
+ }
+
+ /**
+ * @param array $permissions
+ * @return array
+ */
+ private function normalizePermissions(array $permissions, string $scope): array
+ {
+ return collect($permissions)
+ ->map(function (Permission|string $permission) use ($scope): string {
+ if ($permission instanceof Permission) {
+ return $permission->value;
+ }
+
+ return match ($scope) {
+ 'global' => match ($permission) {
+ 'admin', 'global:admin' => Permission::GlobalAdmin->value,
+ 'create', 'project:create' => Permission::ProjectCreate->value,
+ default => $permission,
+ },
+ 'project' => match ($permission) {
+ 'admin', 'delete', 'project:admin' => Permission::ProjectAdmin->value,
+ 'edit' => Permission::ProjectAdmin->value,
+ 'create', 'project:write', 'write' => Permission::ProjectWrite->value,
+ 'project:read', 'read', 'view' => Permission::ProjectRead->value,
+ default => $permission,
+ },
+ 'team' => match ($permission) {
+ 'admin', 'team:admin' => Permission::TeamAdmin->value,
+ 'invite', 'team:invite' => Permission::TeamInvite->value,
+ 'manage', 'team:manage' => Permission::TeamManage->value,
+ default => $permission,
+ },
+ default => $permission,
+ };
+ })
+ ->unique()
+ ->values()
+ ->all();
+ }
}