Compare commits

..

4 Commits

Author SHA1 Message Date
Gregor Vostrak
326b76a07f fix daterange presets, fix e2e test 2025-06-30 12:43:20 +02:00
Gregor Vostrak
af5e894c83 add information about placeholders in delete modal 2025-06-30 12:20:05 +02:00
Gregor Vostrak
ba866751ff add delete modal for member delete with relations
allow admins to delete members
fix Dialog cloes on click outside of content
2025-06-26 16:05:46 +02:00
Constantin Graf
c8d6ad734e Add option to delete members with relations 2025-06-24 17:37:10 +02:00
19 changed files with 53 additions and 271 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'))
->everySixHours();
->twiceDaily();
}
/**

View File

@@ -1,10 +0,0 @@
<?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,7 +4,6 @@ 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;
@@ -51,7 +50,6 @@ class InvitationController extends Controller
*
* @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invite
*/

View File

@@ -10,7 +10,6 @@ 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;
@@ -174,7 +173,6 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invitePlaceholder
*/

View File

@@ -43,10 +43,7 @@ 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', [
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException;
}

View File

@@ -7,8 +7,11 @@ 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
@@ -26,6 +29,10 @@ 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,7 +5,6 @@ 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;
@@ -17,7 +16,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService
{
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
*/
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{
@@ -29,13 +28,6 @@ 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,14 +67,6 @@ 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();
@@ -88,14 +80,6 @@ class MemberService
}
$member->delete();
if ($isPlaceholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
MemberRemoved::dispatch($member, $organization);
}

View File

@@ -9,7 +9,6 @@ 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;
@@ -46,7 +45,6 @@ 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

@@ -17,7 +17,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVar } from '@vueuse/core';
use([
CanvasRenderer,
@@ -47,10 +47,8 @@ const xAxisLabels = computed(() => {
formatDate(el.key ?? '', organization?.value?.date_format)
);
});
const accentColor = useCssVariable('--theme-color-chart');
const labelColor = useCssVariable('--color-text-secondary');
const markLineColor = useCssVariable('--color-border-secondary');
const splitLineColor = useCssVariable('--color-border-tertiary');
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const seriesData = computed(() => {
return props?.groupedData?.map((el) => {
@@ -113,7 +111,7 @@ const option = computed(() => ({
data: xAxisLabels.value,
markLine: {
lineStyle: {
color: markLineColor.value,
color: 'rgba(125,156,188,0.1)',
type: 'dashed',
},
},
@@ -137,13 +135,9 @@ const option = computed(() => ({
},
yAxis: {
type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: {
lineStyle: {
color: splitLineColor.value,
color: 'rgba(125,156,188,0.2)', // Set desired color here
},
},
},

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVar } from '@vueuse/core';
import type { Organization } from '@/packages/api/src';
use([
@@ -36,7 +36,7 @@ type ReportingChartDataEntry = {
const props = defineProps<{
data: ReportingChartDataEntry | null;
}>();
const labelColor = useCssVariable('--color-text-secondary');
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const seriesData = computed(() => {
return props.data?.map((el) => {

View File

@@ -19,7 +19,7 @@ import {
formatHumanReadableDuration,
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVar } from '@vueuse/core';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src';
@@ -64,9 +64,12 @@ const max = computed(() => {
}
});
const backgroundColor = useCssVariable('--theme-color-card-background');
const itemBackgroundColor = useCssVariable('--color-bg-tertiary');
const borderColor = useCssVariable('--color-border');
const backgroundColor = useCssVar('--color-card-background', null, {
observe: true,
});
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, {
observe: true,
});
const option = computed(() => {
return {
@@ -117,7 +120,7 @@ const option = computed(() => {
[],
itemStyle: {
borderRadius: 5,
borderColor: borderColor.value,
borderColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
},
tooltip: {

View File

@@ -1,14 +1,13 @@
<script setup lang="ts">
import VChart from 'vue-echarts';
import { computed } from 'vue';
import { useCssVariable } from '@/utils/useCssVariable';
import { computed, ref } from 'vue';
import { useCssVar } from '@vueuse/core';
const props = defineProps<{
history: number[];
}>();
const accentColor = useCssVariable('--theme-color-chart');
const markLineColor = useCssVariable('--color-border-secondary');
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const seriesData = computed(() => props.history.map((el) => {
return {
@@ -23,7 +22,7 @@ const seriesData = computed(() => props.history.map((el) => {
},
};
}));
const option = computed(() => ({
const option = ref({
grid: {
top: 0,
right: 0,
@@ -36,7 +35,7 @@ const option = computed(() => ({
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
markLine: {
lineStyle: {
color: markLineColor.value,
color: 'rgba(125,156,188,0.1)',
type: 'dashed',
},
},
@@ -67,11 +66,11 @@ const option = computed(() => ({
},
series: [
{
data: seriesData.value,
data: seriesData,
type: 'bar',
},
],
}));
});
</script>
<template>

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVar } from "@vueuse/core";
import type { Organization } from "@/packages/api/src";
use([
@@ -24,7 +24,7 @@ use([
]);
provide(THEME_KEY, 'dark');
const labelColor = useCssVariable('--color-text-secondary');
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const props = defineProps<{
weeklyProjectOverview: {

View File

@@ -18,7 +18,7 @@ import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/utils/useCssVariable';
import { useCssVar } from '@vueuse/core';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -60,7 +60,7 @@ const weekdays = computed(() => {
}
});
const accentColor = useCssVariable('--theme-color-chart');
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
@@ -176,8 +176,10 @@ const seriesData = computed(() => {
});
});
const markLineColor = useCssVariable('--color-border-secondary');
const labelColor = useCssVariable('--color-text-secondary');
const markLineColor = useCssVar('--color-border-secondary', null, {
observe: true,
});
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const option = computed(() => {
return {
tooltip: {
@@ -213,10 +215,6 @@ const option = computed(() => {
},
yAxis: {
type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: {
lineStyle: {
color: markLineColor.value,

View File

@@ -3,6 +3,13 @@ import { computed, watch } from "vue";
type themeOption = "system" | "light" | "dark";
const themeSetting = useStorage<themeOption>("theme", "system");
// reload page when themeSettingChanges
watch(
themeSetting,
() => {
location.reload();
}
)
const preferredColor = usePreferredColorScheme();
const theme = computed(() => {
if(themeSetting.value === "system"){

View File

@@ -1,49 +0,0 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useCssVariable(variableName: string) {
const value = ref('')
let observer: MutationObserver | null = null
let mediaQuery: MediaQueryList | null = null
const updateValue = () => {
const computedStyle = getComputedStyle(document.documentElement)
const cssValue = computedStyle.getPropertyValue(variableName).trim()
value.value = cssValue
}
onMounted(() => {
// Initialize with current value
updateValue()
// Watch for class changes on document.documentElement (where theme classes are applied)
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
updateValue()
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
// Also watch for system color scheme changes
if (window.matchMedia) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateValue)
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
if (mediaQuery) {
mediaQuery.removeEventListener('change', updateValue)
}
})
return value
}

View File

@@ -129,31 +129,26 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('message', 'User is already a member of the organization');
}
public function test_store_fails_if_an_invitation_with_the_same_email_already_exists(): void
public function test_store_fails_if_user_invites_user_who_is_already_invited_to_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:create',
]);
Passport::actingAs($data->user);
$email = 'user@email.test';
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => $email,
]);
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
// Act
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
'email' => $email,
'email' => $invitation->email,
'role' => Role::Employee->value,
]);
// 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.',
$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(422);
}
public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void

View File

@@ -10,7 +10,6 @@ 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;
@@ -654,103 +653,6 @@ 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
@@ -1035,37 +937,6 @@ 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