Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
9cb3aea7be Add checks for placeholder invitation; Fixed bug in member deletion 2025-07-07 16:54:26 +02:00
17 changed files with 209 additions and 88 deletions

View File

@@ -28,7 +28,7 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->twiceDaily();
->everySixHours();
}
/**

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{
public const string KEY = 'invitation_for_the_email_already_exists';
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
@@ -50,6 +51,7 @@ class InvitationController extends Controller
*
* @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invite
*/

View File

@@ -10,6 +10,7 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
@@ -173,6 +174,7 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invitePlaceholder
*/

View File

@@ -43,7 +43,10 @@ class Controller extends BaseController
/** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
throw new AuthorizationException;
}

View File

@@ -7,11 +7,8 @@ namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization
@@ -29,10 +26,6 @@ class InvitationStoreRequest extends BaseFormRequest
'email' => [
'required',
'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'),
],
'role' => [
'required',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail;
use App\Models\Member;
@@ -16,7 +17,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService
{
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{
@@ -28,6 +29,13 @@ class InvitationService
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
if (OrganizationInvitation::query()
->where('email', $email)
->whereBelongsTo($organization, 'organization')
->exists()) {
throw new InvitationForTheEmailAlreadyExistsApiException;
}
InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation;

View File

@@ -67,6 +67,14 @@ class MemberService
throw new CanNotRemoveOwnerFromOrganization;
}
$user = $member->user;
$isPlaceholder = $user->is_placeholder;
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
if ($withRelations) {
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
@@ -80,6 +88,14 @@ class MemberService
}
$member->delete();
if ($isPlaceholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
MemberRemoved::dispatch($member, $organization);
}

View File

@@ -9,6 +9,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
@@ -45,6 +46,7 @@ return [
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -6,8 +6,8 @@ import {
} from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon, XIcon } from 'lucide-vue-next';
import { formatDate } from '@/packages/ui/src/utils/time';
import { CalendarIcon } from 'lucide-vue-next';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { parseDate } from '@internationalized/date';
import { computed, inject, type ComputedRef } from 'vue';
import { type Organization } from '@/packages/api/src';
@@ -17,10 +17,6 @@ const emit = defineEmits<{
blur: [];
}>();
defineProps<{
clearable?: boolean;
}>();
const handleChange = (date: string) => {
model.value = date;
};
@@ -29,11 +25,6 @@ const handleBlur = () => {
emit('blur');
};
const handleClear = (event: Event) => {
event.stopPropagation();
model.value = null;
};
const date = computed(() => {
return model.value ? parseDate(model.value) : undefined;
});
@@ -53,17 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
]"
>
<CalendarIcon class="mr-2 h-4 w-4" />
<span class="flex-1">
{{ model ? formatDate(model, organization?.date_format) : 'Pick a date' }}
</span>
<button
v-if="clearable && model"
class="ml-2 hover:bg-muted rounded p-1 transition-colors"
type="button"
@click="handleClear"
>
<XIcon class="h-4 w-4" />
</button>
{{ model ? formatDateLocalized(model, organization?.date_format) : 'Pick a date' }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">

View File

@@ -67,7 +67,6 @@ const InvoiceResource = z
status: z.string(),
date: z.string(),
due_at: z.string(),
paid_date: z.string(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
})
@@ -77,7 +76,7 @@ const InvoiceDiscountType = z.enum(['percentage', 'fixed']);
const InvoiceStoreRequest = z
.object({
due_at: z.union([z.string(), z.null()]).optional(),
paid_date: z.union([z.string(), z.null()]).optional(),
paid_at: z.union([z.string(), z.null()]).optional(),
seller_name: z.string(),
seller_vatin: z.union([z.string(), z.null()]).optional(),
seller_address_line_1: z.union([z.string(), z.null()]).optional(),
@@ -103,13 +102,8 @@ const InvoiceStoreRequest = z
billing_period_end: z.union([z.string(), z.null()]).optional(),
reference: z.string(),
currency: z.string(),
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
discount_amount: z
.number()
.int()
.gte(0)
.lte(9223372036854776000)
.optional(),
tax_rate: z.number().int().optional(),
discount_amount: z.number().int().optional(),
discount_type: InvoiceDiscountType.optional(),
footer: z.union([z.string(), z.null()]).optional(),
notes: z.union([z.string(), z.null()]).optional(),
@@ -121,12 +115,8 @@ const InvoiceStoreRequest = z
.object({
name: z.string(),
description: z.union([z.string(), z.null()]).optional(),
unit_price: z
.number()
.int()
.gte(0)
.lte(9223372036854776000),
quantity: z.number().gte(0).lte(99999999),
unit_price: z.number().int().gte(0).lte(99999999),
quantity: z.number().gte(0),
})
.passthrough()
)
@@ -171,7 +161,7 @@ const DetailedInvoiceResource = z
buyer_address_country: z.string(),
buyer_phone: z.string(),
buyer_email: z.string(),
paid_date: z.string(),
paid_at: z.union([z.string(), z.null()]),
due_at: z.string(),
discount_type: z.string(),
discount_amount: z.number().int(),
@@ -195,7 +185,7 @@ const InvoiceUpdateRequest = z
.object({
status: InvoiceStatus,
due_at: z.union([z.string(), z.null()]),
paid_date: z.union([z.string(), z.null()]),
paid_at: z.union([z.string(), z.null()]),
seller_name: z.string(),
seller_vatin: z.union([z.string(), z.null()]),
seller_address_line_1: z.union([z.string(), z.null()]),
@@ -221,8 +211,8 @@ const InvoiceUpdateRequest = z
billing_period_end: z.union([z.string(), z.null()]),
reference: z.string(),
currency: z.string(),
tax_rate: z.number().int().gte(0).lte(2147483647),
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
tax_rate: z.number().int(),
discount_amount: z.number().int(),
discount_type: InvoiceDiscountType,
footer: z.union([z.string(), z.null()]),
notes: z.union([z.string(), z.null()]),
@@ -234,12 +224,8 @@ const InvoiceUpdateRequest = z
id: z.union([z.string(), z.null()]).optional(),
name: z.string(),
description: z.union([z.string(), z.null()]).optional(),
unit_price: z
.number()
.int()
.gte(0)
.lte(9223372036854776000),
quantity: z.number().gte(0).lte(99999999),
unit_price: z.number().int().gte(0).lte(99999999),
quantity: z.number().gte(0),
})
.passthrough()
),

View File

@@ -154,13 +154,13 @@ function onSelectChange(checked: boolean) {
"></BillableToggleButton>
<div class="flex-1">
<button
:class="twMerge('text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]')"
:class="twMerge('hidden lg:block text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]')"
@click="expanded = !expanded">
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
</button>
</div>
<button
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
@click="expanded = !expanded">
{{
formatHumanReadableDuration(
@@ -173,7 +173,7 @@ function onSelectChange(checked: boolean) {
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
class="opacity-20 hidden sm:flex group-hover:opacity-100 focus-visible:opacity-100"
@changed="
onStartStopClick(timeEntry)
"></TimeTrackerStartStop>

View File

@@ -144,6 +144,7 @@ function onSelectChange(checked : boolean) {
"></BillableToggleButton>
<div class="flex-1">
<TimeEntryRangeSelector
class="hidden lg:block"
:start="timeEntry.start"
:end="timeEntry.end"
:show-date
@@ -159,7 +160,7 @@ function onSelectChange(checked : boolean) {
"></TimeEntryRowDurationInput>
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
class="opacity-20 hidden sm:flex focus-visible:opacity-100 group-hover:opacity-100"
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="

View File

@@ -82,7 +82,7 @@ function selectInput(event: Event) {
v-model="currentTime"
data-testid="time_entry_duration_input"
name="Duration"
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput"
@keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate"

View File

@@ -160,29 +160,12 @@ export function formatWeek(date: string | null): string {
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatHumanReadableDate(date: string) {
const dateObj = dayjs(date);
const today = dayjs();
if (dateObj.isToday()) {
if (dayjs(date).isToday()) {
return 'Today';
} else if (dateObj.isYesterday()) {
} else if (dayjs(date).isYesterday()) {
return 'Yesterday';
}
// Calculate difference in days
const diffInDays = today.diff(dateObj, 'day');
if (diffInDays > 0 && diffInDays <= 30) {
// For dates in the past (2-30 days ago)
return `${diffInDays} ${diffInDays === 1 ? 'day' : 'days'} ago`;
} else if (diffInDays < 0 && diffInDays >= -30) {
// For dates in the future (within 30 days)
const futureDays = Math.abs(diffInDays);
return `In ${futureDays} ${futureDays === 1 ? 'day' : 'days'}`;
}
// For dates older than 30 days, show the actual date
return dateObj.format('MMM D, YYYY');
return dayjs(date).fromNow();
}
export function formatWeekday(date: string) {

View File

@@ -129,26 +129,31 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('message', 'User is already a member of the organization');
}
public function test_store_fails_if_user_invites_user_who_is_already_invited_to_organization(): void
public function test_store_fails_if_an_invitation_with_the_same_email_already_exists(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:create',
]);
Passport::actingAs($data->user);
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
$email = 'user@email.test';
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => $email,
]);
// Act
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
'email' => $invitation->email,
'email' => $email,
'role' => Role::Employee->value,
]);
// Assert
$response->assertInvalid([
'email' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'invitation_for_the_email_already_exists',
'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
]);
$response->assertStatus(422);
}
public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void

View File

@@ -10,6 +10,7 @@ use App\Events\MemberRemoved;
use App\Http\Controllers\Api\V1\MemberController;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
@@ -653,6 +654,103 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
Event::assertNotDispatched(MemberRemoved::class);
}
public function test_destroy_endpoint_also_deletes_user_if_member_is_placeholder(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$user = User::factory()->placeholder()->create();
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();
Passport::actingAs($data->user);
Event::fake([
MemberRemoved::class,
]);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $member->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $member->getKey(),
]);
$this->assertDatabaseMissing(User::class, [
'id' => $user->getKey(),
]);
Event::assertDispatched(function (MemberRemoved $event) use ($data, $member): bool {
return $event->organization->is($data->organization) &&
$event->member->is($member);
}, 1);
}
public function test_destroy_endpoint_sets_current_organization_to_organization_the_user_is_still_member_of(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$user = $data->user;
$otherOrganization = Organization::factory()->create();
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($user)->role(Role::Employee)->create();
Passport::actingAs($user);
Event::fake([
MemberRemoved::class,
]);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $data->member->getKey(),
]);
$user->refresh();
$this->assertSame($otherOrganization->getKey(), $user->currentOrganization->getKey());
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
return $event->organization->is($data->organization) &&
$event->member->is($data->member);
}, 1);
}
public function test_destroy_endpoint_creates_new_organization_and_sets_the_current_organization_to_it_if_user_is_not_member_of_any_other_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$organization = $data->organization;
$user = $data->user;
Passport::actingAs($user);
Event::fake([
MemberRemoved::class,
]);
$this->assertDatabaseCount(Organization::class, 1);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseCount(Organization::class, 2);
$newOrganization = Organization::where('id', '!=', $organization->getKey())->first();
$this->assertNotNull($newOrganization);
$this->assertDatabaseMissing(Member::class, [
'id' => $data->member->getKey(),
]);
$this->assertDatabaseHas(Member::class, [
'organization_id' => $newOrganization->getKey(),
'user_id' => $user->getKey(),
]);
$user->refresh();
$this->assertNotNull($user->currentOrganization);
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
return $event->organization->is($data->organization) &&
$event->member->is($data->member);
}, 1);
}
public function test_destroy_endpoint_succeeds_if_member_is_still_in_use_by_a_project_member_and_delete_related_is_active(): void
{
// Arrange
@@ -937,6 +1035,37 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_invite_placeholder_fails_if_there_is_already_an_invitation_with_the_same_email(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:invite-placeholder',
'invitations:create',
]);
$placeholder = User::factory()->placeholder()->create([
'email' => 'user@mail.test',
]);
$placeholderMember = Member::factory()->forUser($placeholder)->forOrganization($data->organization)->role(Role::Placeholder)->create();
OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => $placeholder->email,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->id,
'member' => $placeholderMember->id,
]));
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'invitation_for_the_email_already_exists',
'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
]);
}
public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void
{
// Arrange