Compare commits

..

24 Commits

Author SHA1 Message Date
Gregor Vostrak
0ec3b732ad allow employee manage task setting to organization 2025-11-25 15:27:47 +01:00
Gregor Vostrak
b1bb7245b0 use default api limit for fetching time entries 2025-11-20 17:30:13 +01:00
Gregor Vostrak
6f37ad500a limit initially loaded time entries on time page 2025-11-20 16:58:53 +01:00
Gregor Vostrak
500ccd5719 fix container queries for time entry rows 2025-11-20 16:52:08 +01:00
Gregor Vostrak
bacd6f4222 include the currently running time entry in the calendar header 2025-11-20 13:17:48 +01:00
Gregor Vostrak
022caf59ee bump solidtime ui package version to 0.0.13 2025-11-19 17:34:21 +01:00
Gregor Vostrak
f955ab3135 fix display problems caused by minimum height of calendar events 2025-11-19 17:34:21 +01:00
Gregor Vostrak
5b491b0da2 add support for currently running time entry 2025-11-19 17:34:21 +01:00
Gregor Vostrak
249ab67ac8 improve idle indicator colors, fix typescript issues 2025-11-19 17:34:21 +01:00
Gregor Vostrak
1bd2c28b37 add tooltips to idlestatus indicators 2025-11-19 17:34:21 +01:00
Gregor Vostrak
33ac994cc0 add activity status plugin to calendar 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8d3ee58bed improve initial mount performance for groupedtimeentrytable by streaming in the rows
mounting the rows mounts lots of nested components which results in a delay on the initial mount.
2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2c260533 use container queries for time entry table 2025-11-19 17:34:21 +01:00
Gregor Vostrak
95ab1699c4 make sure that CreateTimeEntry modal always starts with times that have 0 seconds 2025-11-19 17:34:21 +01:00
Gregor Vostrak
306a081a3d prevent seconds update on timepicker when nothing else changes 2025-11-19 17:34:21 +01:00
Gregor Vostrak
878ac4ab81 add tooltip component 2025-11-19 17:34:21 +01:00
Gregor Vostrak
947550d639 move css variables and tailwind theme config into ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
09fb5aa48e make sure that timepicker and calendar set seconds to 0 on update, fixes #968 2025-11-19 17:34:21 +01:00
Gregor Vostrak
9b9371e5a5 move button component to ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
0648437478 design fixes, improve component encapsulation 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8ba04eca0c move currency and cancreateproject permission to props to decouple TimeEntryCreateModal from web 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2f35de0c fix package build error dependencies 2025-11-19 17:34:21 +01:00
Gregor Vostrak
b7dafb0892 bump api and ui package versions 2025-11-19 17:34:21 +01:00
Gregor Vostrak
6eca0c2c76 fix archived_at timestamp of client in exporter 2025-11-11 12:55:33 +01:00
19 changed files with 531 additions and 75 deletions

View File

@@ -46,6 +46,9 @@ class OrganizationController extends Controller
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();
}

View File

@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
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 @@ class TaskController extends Controller
}
}
/**
* 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 @@ class TaskController extends Controller
*/
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 @@ class TaskController extends Controller
*/
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 @@ class TaskController extends Controller
*/
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');

View File

@@ -39,6 +39,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
@@ -102,6 +105,11 @@ class OrganizationUpdateRequest extends BaseFormRequest
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;

View File

@@ -53,6 +53,8 @@ class OrganizationResource extends BaseResource
'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) */

View File

@@ -35,6 +35,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @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,

View File

@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
'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 @@ class JetstreamServiceProvider extends ServiceProvider
'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 @@ class JetstreamServiceProvider extends ServiceProvider
'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',

View File

@@ -167,7 +167,7 @@ class ExportService
$client->id,
$client->name,
$client->organization_id,
$client->archived_at ?? '',
$client->archived_at?->toIso8601ZuluString() ?? '',
$client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '',
]);

View File

@@ -71,7 +71,19 @@ class PermissionStore
/** @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;
}
/**

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -383,7 +383,7 @@ async function downloadExport(format: ExportFormat) {
@submit="clearSelectionAndState"
@select-all="selectedTimeEntries = [...timeEntries]"
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
<div class="w-full relative">
<div class="w-full relative @container">
<div v-for="entry in timeEntries" :key="entry.id">
<TimeEntryRow
:selected="selectedTimeEntries.includes(entry)"

View File

@@ -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,
});
}
</script>
<template>
<FormSection>
<template #title>Time Entry Settings</template>
<template #title>Organization Settings</template>
<template #description>
Disallow overlapping time entries for members of this organization. When enabled, users
cannot create new time entries that overlap with their existing ones. This only affects
newly created entries.
Configure various settings for your organization, including time entry and task
management permissions.
</template>
<template #form>
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<div class="col-span-6 sm:col-span-4 space-y-4">
<div class="flex items-center space-x-2">
<Checkbox
id="preventOverlappingTimeEntries"
@@ -57,6 +62,14 @@ async function submit() {
for="preventOverlappingTimeEntries"
value="Prevent overlapping time entries (new entries only)" />
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="employeesCanManageTasks"
v-model:checked="form.employees_can_manage_tasks" />
<InputLabel
for="employeesCanManageTasks"
value="Allow Employees to manage tasks" />
</div>
</div>
</div>
</template>

View File

@@ -317,6 +317,7 @@ const OrganizationResource = z
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
employees_can_manage_tasks: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
@@ -332,6 +333,7 @@ const OrganizationUpdateRequest = z
name: z.string().max(255),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
employees_can_manage_tasks: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
number_format: NumberFormat,
currency_format: CurrencyFormat,

View File

@@ -177,16 +177,23 @@ const events = computed(() => {
// Daily totals used in day header
const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
props.timeEntries
.filter((entry) => entry.end !== null)
.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
const duration = getDayJsInstance()(entry.end!).diff(
props.timeEntries.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
let duration: number;
if (entry.end !== null) {
// Completed entry
duration = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'minutes'
);
totals[date] = (totals[date] || 0) + duration;
});
} else {
// Running entry - use current time
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
}
totals[date] = (totals[date] || 0) + duration;
});
return totals;
});

View File

@@ -93,7 +93,7 @@ function onSelectChange(checked: boolean) {
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div class="@sm:flex py-2 items-center min-w-0 justify-between group">
<div class="@xl:flex py-2 items-center min-w-0 justify-between group">
<div class="flex space-x-3 items-center min-w-0">
<Checkbox
:checked="

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
@@ -38,8 +38,6 @@ const props = defineProps<{
canCreateProject: boolean;
}>();
const maxVisibleGroups = ref(7); // Start with 10 day groups, then show all
const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
for (const entry of props.timeEntries) {
@@ -137,43 +135,11 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
);
});
}
const visibleGroupedEntries = computed(() => {
const allGroups = Object.entries(groupedTimeEntries.value);
return Object.fromEntries(allGroups.slice(0, maxVisibleGroups.value));
});
const totalGroups = computed(() => Object.keys(groupedTimeEntries.value).length);
function startProgressiveLoading() {
const loadMoreGroups = () => {
if (maxVisibleGroups.value < totalGroups.value) {
maxVisibleGroups.value = Math.min(maxVisibleGroups.value + 5, totalGroups.value);
if (maxVisibleGroups.value < totalGroups.value) {
requestIdleCallback(loadMoreGroups);
}
}
};
requestIdleCallback(loadMoreGroups);
}
// Watch for changes to totalGroups and adjust maxVisibleGroups accordingly
watch(totalGroups, (newTotal, oldTotal) => {
if (newTotal !== oldTotal) {
maxVisibleGroups.value = newTotal;
}
});
onMounted(() => {
startProgressiveLoading();
});
</script>
<template>
<div class="@container">
<div v-for="(value, key) in visibleGroupedEntries" :key="key">
<div v-for="(value, key) in groupedTimeEntries" :key="key">
<TimeEntryRowHeading
:date="String(key)"
:duration="sumDuration(value)"

View File

@@ -112,7 +112,7 @@ async function handleDeleteTimeEntry() {
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div class="@sm:flex py-2 min-w-0 items-center justify-between group">
<div class="@xl:flex py-2 min-w-0 items-center justify-between group">
<div class="flex items-center min-w-0">
<Checkbox :checked="selected" @update:checked="onSelectChange" />
<div v-if="indent === true" class="w-10 h-7"></div>

View File

@@ -183,7 +183,7 @@ body {
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--theme-color-button-primary-text);
--primary-foreground: var(--color-text-primary);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
@@ -210,7 +210,7 @@ body {
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--theme-color-button-primary-text);
--primary-foreground: var(--color-text-primary);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);

View File

@@ -299,7 +299,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create',
'tasks:create:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create([
@@ -324,7 +324,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create',
'tasks:create:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->create();
@@ -352,7 +352,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create',
'tasks:create:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -376,7 +376,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create',
'tasks:create:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -408,7 +408,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create',
'tasks:create:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -465,7 +465,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update',
'tasks:update:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$name = 'Task 1';
@@ -493,7 +493,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update',
'tasks:update:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->create();
@@ -523,7 +523,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update',
'tasks:update:all',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -547,7 +547,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
$now = Carbon::now();
$this->travelTo($now);
$data = $this->createUserWithPermission([
'tasks:update',
'tasks:update:all',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -570,7 +570,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update',
'tasks:update:all',
]);
$task = Task::factory()->forOrganization($data->organization)->isDone()->create();
Passport::actingAs($data->user);
@@ -593,7 +593,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update',
'tasks:update:all',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -621,7 +621,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update',
'tasks:update:all',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -650,7 +650,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:delete',
'tasks:delete:all',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -669,7 +669,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:delete',
'tasks:delete:all',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forMember($data->member)->forTask($task)->forOrganization($data->organization)->create();
@@ -707,10 +707,10 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:delete',
'tasks:delete:all',
]);
$otherData = $this->createUserWithPermission([
'tasks:delete',
'tasks:delete:all',
]);
$task = Task::factory()->forOrganization($otherData->organization)->create();
Passport::actingAs($data->user);
@@ -724,4 +724,274 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
'id' => $task->getKey(),
]);
}
public function test_store_endpoint_allows_employee_to_create_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_allows_employee_to_create_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_fails_for_employee_creating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
}
public function test_store_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Task::class, [
'name' => 'Employee Task',
]);
}
public function test_update_endpoint_allows_employee_to_update_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => 'Updated by Employee',
]);
}
public function test_update_endpoint_allows_employee_to_update_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => 'Updated by Employee',
]);
}
public function test_update_endpoint_fails_for_employee_updating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
$originalName = $task->name;
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => $originalName,
]);
}
public function test_update_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
$originalName = $task->name;
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => $originalName,
]);
}
public function test_delete_endpoint_allows_employee_to_delete_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_allows_employee_to_delete_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_fails_for_employee_deleting_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
]);
}
}

View File

@@ -124,4 +124,88 @@ class PermissionStoreTest extends TestCase
// Assert
$this->assertSame(Jetstream::findRole(Role::Employee->value)->permissions, $result);
}
public function test_employee_does_not_have_task_permissions_by_default(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => false,
]);
$user = User::factory()->create();
$organization->users()->attach($user, ['role' => Role::Employee->value]);
$permissionStore = new PermissionStore;
$this->actingAs($user);
// Act & Assert
$this->assertFalse($permissionStore->has($organization, 'tasks:create'));
$this->assertFalse($permissionStore->has($organization, 'tasks:update'));
$this->assertFalse($permissionStore->has($organization, 'tasks:delete'));
$this->assertFalse($permissionStore->has($organization, 'tasks:create:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:update:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:delete:all'));
}
public function test_employee_has_task_permissions_when_organization_allows_it(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => true,
]);
$user = User::factory()->create();
$organization->users()->attach($user, ['role' => Role::Employee->value]);
$permissionStore = new PermissionStore;
$this->actingAs($user);
// Act & Assert
$this->assertTrue($permissionStore->has($organization, 'tasks:create'));
$this->assertTrue($permissionStore->has($organization, 'tasks:update'));
$this->assertTrue($permissionStore->has($organization, 'tasks:delete'));
// Should NOT have the :all permissions
$this->assertFalse($permissionStore->has($organization, 'tasks:create:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:update:all'));
$this->assertFalse($permissionStore->has($organization, 'tasks:delete:all'));
}
public function test_non_employee_roles_are_not_affected_by_employees_can_manage_tasks_setting(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => false,
]);
$admin = User::factory()->create();
$organization->users()->attach($admin, ['role' => Role::Admin->value]);
$permissionStore = new PermissionStore;
$this->actingAs($admin);
// Act & Assert - Admin should have task permissions regardless of the setting
$this->assertTrue($permissionStore->has($organization, 'tasks:create'));
$this->assertTrue($permissionStore->has($organization, 'tasks:update'));
$this->assertTrue($permissionStore->has($organization, 'tasks:delete'));
$this->assertTrue($permissionStore->has($organization, 'tasks:create:all'));
$this->assertTrue($permissionStore->has($organization, 'tasks:update:all'));
$this->assertTrue($permissionStore->has($organization, 'tasks:delete:all'));
}
public function test_get_permissions_includes_task_permissions_for_employee_when_enabled(): void
{
// Arrange
$organization = Organization::factory()->create([
'employees_can_manage_tasks' => true,
]);
$user = User::factory()->create();
$organization->users()->attach($user, ['role' => Role::Employee->value]);
$permissionStore = new PermissionStore;
$this->actingAs($user);
// Act
$result = $permissionStore->getPermissions($organization);
// Assert
$this->assertContains('tasks:create', $result);
$this->assertContains('tasks:update', $result);
$this->assertContains('tasks:delete', $result);
$this->assertNotContains('tasks:create:all', $result);
$this->assertNotContains('tasks:update:all', $result);
$this->assertNotContains('tasks:delete:all', $result);
}
}