diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index a046535b..4475acc1 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -46,6 +46,9 @@ public function update(Organization $organization, OrganizationUpdateRequest $re if ($request->getEmployeesCanSeeBillableRates() !== null) { $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); } + if ($request->getEmployeesCanManageTasks() !== null) { + $organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks(); + } if ($request->getNumberFormat() !== null) { $organization->number_format = $request->getNumberFormat(); } diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 8c90f215..68a371ed 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -11,6 +11,7 @@ use App\Http\Resources\V1\Task\TaskCollection; use App\Http\Resources\V1\Task\TaskResource; use App\Models\Organization; +use App\Models\Project; use App\Models\Task; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; @@ -27,6 +28,26 @@ protected function checkPermission(Organization $organization, string $permissio } } + /** + * Check scoped permission and verify user has access to the project + * + * @throws AuthorizationException + */ + private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void + { + $this->checkPermission($organization, $permission); + + $user = $this->user(); + $hasAccess = Project::query() + ->where('id', $project->id) + ->visibleByEmployee($user) + ->exists(); + + if (! $hasAccess) { + throw new AuthorizationException('You do not have permission to '.$permission.' in this project.'); + } + } + /** * Get tasks * @@ -75,7 +96,15 @@ public function index(Organization $organization, TaskIndexRequest $request): Ta */ public function store(Organization $organization, TaskStoreRequest $request): JsonResource { - $this->checkPermission($organization, 'tasks:create'); + /** @var Project $project */ + $project = Project::query()->findOrFail($request->input('project_id')); + + if ($this->hasPermission($organization, 'tasks:create:all')) { + $this->checkPermission($organization, 'tasks:create:all'); + } else { + $this->checkScopedPermissionForProject($organization, $project, 'tasks:create'); + } + $task = new Task; $task->name = $request->input('name'); $task->project_id = $request->input('project_id'); @@ -97,7 +126,17 @@ public function store(Organization $organization, TaskStoreRequest $request): Js */ public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource { - $this->checkPermission($organization, 'tasks:update', $task); + // Check task belongs to organization + if ($task->organization_id !== $organization->id) { + throw new AuthorizationException('Task does not belong to organization'); + } + + if ($this->hasPermission($organization, 'tasks:update:all')) { + $this->checkPermission($organization, 'tasks:update:all'); + } else { + $this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update'); + } + $task->name = $request->input('name'); if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { $task->estimated_time = $request->getEstimatedTime(); @@ -119,7 +158,16 @@ public function update(Organization $organization, Task $task, TaskUpdateRequest */ public function destroy(Organization $organization, Task $task): JsonResponse { - $this->checkPermission($organization, 'tasks:delete', $task); + // Check task belongs to organization + if ($task->organization_id !== $organization->id) { + throw new AuthorizationException('Task does not belong to organization'); + } + + if ($this->hasPermission($organization, 'tasks:delete:all')) { + $this->checkPermission($organization, 'tasks:delete:all'); + } else { + $this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete'); + } if ($task->timeEntries()->exists()) { throw new EntityStillInUseApiException('task', 'time_entry'); diff --git a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php index 042fcfc0..4def93fe 100644 --- a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -39,6 +39,9 @@ public function rules(): array 'employees_can_see_billable_rates' => [ 'boolean', ], + 'employees_can_manage_tasks' => [ + 'boolean', + ], 'prevent_overlapping_time_entries' => [ 'boolean', ], @@ -102,6 +105,11 @@ public function getEmployeesCanSeeBillableRates(): ?bool return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null; } + public function getEmployeesCanManageTasks(): ?bool + { + return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null; + } + public function getPreventOverlappingTimeEntries(): ?bool { return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null; diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php index e7baa9e9..4faa9e2f 100644 --- a/app/Http/Resources/V1/Organization/OrganizationResource.php +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -53,6 +53,8 @@ public function toArray(Request $request): array 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, /** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */ 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, + /** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */ + 'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks, /** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */ 'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries, /** @var string $currency Currency code (ISO 4217) */ diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 35710e10..3cfab262 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -35,6 +35,7 @@ * @property int|null $billable_rate * @property string $user_id * @property bool $employees_can_see_billable_rates + * @property bool $employees_can_manage_tasks * @property User $owner * @property Carbon|null $created_at * @property Carbon|null $updated_at @@ -70,6 +71,7 @@ class Organization extends JetstreamTeam implements AuditableContract 'personal_team' => 'boolean', 'currency' => 'string', 'employees_can_see_billable_rates' => 'boolean', + 'employees_can_manage_tasks' => 'boolean', 'prevent_overlapping_time_entries' => 'boolean', 'number_format' => NumberFormat::class, 'currency_format' => CurrencyFormat::class, diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 610c2fd3..6a2fb0c4 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -94,8 +94,11 @@ protected function configurePermissions(): void 'tasks:view', 'tasks:view:all', 'tasks:create', + 'tasks:create:all', 'tasks:update', + 'tasks:update:all', 'tasks:delete', + 'tasks:delete:all', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', @@ -158,8 +161,11 @@ protected function configurePermissions(): void 'tasks:view', 'tasks:view:all', 'tasks:create', + 'tasks:create:all', 'tasks:update', + 'tasks:update:all', 'tasks:delete', + 'tasks:delete:all', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', @@ -219,8 +225,11 @@ protected function configurePermissions(): void 'tasks:view', 'tasks:view:all', 'tasks:create', + 'tasks:create:all', 'tasks:update', + 'tasks:update:all', 'tasks:delete', + 'tasks:delete:all', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', diff --git a/app/Service/PermissionStore.php b/app/Service/PermissionStore.php index 754badcd..d346691c 100644 --- a/app/Service/PermissionStore.php +++ b/app/Service/PermissionStore.php @@ -71,7 +71,19 @@ private function getPermissionsByUser(Organization $organization, User $user): a /** @var Role|null $roleObj */ $roleObj = Jetstream::findRole($role); - return $roleObj->permissions ?? []; + $permissions = $roleObj->permissions ?? []; + + // If the organization allows employees to manage tasks and the user is an employee, + // add the task management permissions for accessible projects + if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) { + $permissions = array_merge($permissions, [ + 'tasks:create', + 'tasks:update', + 'tasks:delete', + ]); + } + + return $permissions; } /** diff --git a/database/migrations/2025_10_24_120845_add_employees_can_manage_tasks_to_organizations_table.php b/database/migrations/2025_10_24_120845_add_employees_can_manage_tasks_to_organizations_table.php new file mode 100644 index 00000000..b3e5f421 --- /dev/null +++ b/database/migrations/2025_10_24_120845_add_employees_can_manage_tasks_to_organizations_table.php @@ -0,0 +1,30 @@ +boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('organizations', function (Blueprint $table): void { + $table->dropColumn('employees_can_manage_tasks'); + }); + } +}; diff --git a/resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue b/resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue index ff7264e7..0cb1b434 100644 --- a/resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue +++ b/resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue @@ -14,13 +14,18 @@ const { updateOrganization } = store; const { organization } = storeToRefs(store); const queryClient = useQueryClient(); -const form = ref<{ prevent_overlapping_time_entries: boolean }>({ +const form = ref<{ + prevent_overlapping_time_entries: boolean; + employees_can_manage_tasks: boolean; +}>({ prevent_overlapping_time_entries: false, + employees_can_manage_tasks: false, }); onMounted(async () => { form.value.prevent_overlapping_time_entries = organization.value?.prevent_overlapping_time_entries ?? false; + form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false; }); const mutation = useMutation({ @@ -33,22 +38,22 @@ const mutation = useMutation({ async function submit() { await mutation.mutateAsync({ prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries, + employees_can_manage_tasks: form.value.employees_can_manage_tasks, }); }