mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-13 16:23:18 +01:00
Compare commits
2 Commits
feature/st
...
feature/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d1d9a4ee7 | ||
|
|
ba778bdddf |
@@ -51,8 +51,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
|||||||
])
|
])
|
||||||
->whereHas('user', function (Builder $query): void {
|
->whereHas('user', function (Builder $query): void {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
$query->where('is_placeholder', '=', false)
|
$query->where('is_placeholder', '=', false);
|
||||||
->where('send_time_entry_still_running_email', '=', true);
|
|
||||||
})
|
})
|
||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
|
|||||||
*/
|
*/
|
||||||
public function report(): bool
|
public function report(): bool
|
||||||
{
|
{
|
||||||
// TODO: temporary activated
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class CanNotRemoveOwnerFromOrganization extends ApiException
|
class CanNotRemoveOwnerFromOrganization extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'can_not_remove_owner_from_organization';
|
public const string KEY = 'can_not_remove_owner_from_organization';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ class EntityStillInUseApiException extends ApiException
|
|||||||
|
|
||||||
public const string KEY = 'entity_still_in_use';
|
public const string KEY = 'entity_still_in_use';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the exception.
|
||||||
|
*
|
||||||
|
* @return bool true means the exception handler will not report it again
|
||||||
|
*/
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the translated message for the exception.
|
* Get the translated message for the exception.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'feature_is_not_available_in_free_plan';
|
public const string KEY = 'feature_is_not_available_in_free_plan';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class InactiveUserCanNotBeUsedApiException extends ApiException
|
class InactiveUserCanNotBeUsedApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'inactive_user_can_not_be_used';
|
public const string KEY = 'inactive_user_can_not_be_used';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'invitation_for_the_email_already_exists';
|
public const string KEY = 'invitation_for_the_email_already_exists';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OnlyOwnerCanChangeOwnership extends ApiException
|
class OnlyOwnerCanChangeOwnership extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'only_owner_can_change_ownership';
|
public const string KEY = 'only_owner_can_change_ownership';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'organization_needs_at_least_one_owner';
|
public const string KEY = 'organization_needs_at_least_one_owner';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OverlappingTimeEntryApiException extends ApiException
|
class OverlappingTimeEntryApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'overlapping_time_entry';
|
public const string KEY = 'overlapping_time_entry';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'time_entry_can_not_be_restarted';
|
public const string KEY = 'time_entry_can_not_be_restarted';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
|
|||||||
class TimeEntryStillRunningApiException extends ApiException
|
class TimeEntryStillRunningApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'time_entry_still_running';
|
public const string KEY = 'time_entry_still_running';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the exception.
|
||||||
|
*
|
||||||
|
* @return bool true means the exception handler will not report it again
|
||||||
|
*/
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_is_already_member_of_organization';
|
public const string KEY = 'user_is_already_member_of_organization';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_is_already_member_of_project';
|
public const string KEY = 'user_is_already_member_of_project';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserNotPlaceholderApiException extends ApiException
|
class UserNotPlaceholderApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_not_placeholder';
|
public const string KEY = 'user_not_placeholder';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Exceptions;
|
|||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||||
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
@@ -34,6 +35,10 @@ class Handler extends ExceptionHandler
|
|||||||
//
|
//
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
|
||||||
|
&& $e->getErrorType() === 'access_denied'
|
||||||
|
&& $e->getHttpStatusCode() === 401);
|
||||||
|
|
||||||
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
|
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
|
||||||
// otherwise renders as a bare "Bad request." 400. Show a message that
|
// otherwise renders as a bare "Bad request." 400. Show a message that
|
||||||
// says how to fix it instead. The framework has already converted the
|
// says how to fix it instead. The framework has already converted the
|
||||||
|
|||||||
@@ -124,10 +124,6 @@ class UserController extends Controller
|
|||||||
$user->week_start = $request->getWeekStart();
|
$user->week_start = $request->getWeekStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
|
|
||||||
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
if ($emailToVerify !== null) {
|
if ($emailToVerify !== null) {
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
'week_start' => [
|
'week_start' => [
|
||||||
Rule::enum(Weekday::class),
|
Rule::enum(Weekday::class),
|
||||||
],
|
],
|
||||||
'send_time_entry_still_running_email' => [
|
|
||||||
'boolean',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +81,6 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSendTimeEntryStillRunningEmail(): ?bool
|
|
||||||
{
|
|
||||||
return $this->has('send_time_entry_still_running_email')
|
|
||||||
? $this->boolean('send_time_entry_still_running_email')
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasPhotoKey(): bool
|
public function hasPhotoKey(): bool
|
||||||
{
|
{
|
||||||
return $this->has('photo');
|
return $this->has('photo');
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ class UserResource extends BaseResource
|
|||||||
'timezone' => $this->resource->timezone,
|
'timezone' => $this->resource->timezone,
|
||||||
/** @var Weekday $week_start Starting day of the week */
|
/** @var Weekday $week_start Starting day of the week */
|
||||||
'week_start' => $this->resource->week_start->value,
|
'week_start' => $this->resource->week_start->value,
|
||||||
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
|
|
||||||
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $timezone
|
* @property string $timezone
|
||||||
* @property bool $is_placeholder
|
* @property bool $is_placeholder
|
||||||
* @property Weekday $week_start
|
* @property Weekday $week_start
|
||||||
* @property bool $send_time_entry_still_running_email
|
|
||||||
* @property string|null $profile_photo_path
|
* @property string|null $profile_photo_path
|
||||||
* @property-read Organization|null $currentOrganization
|
* @property-read Organization|null $currentOrganization
|
||||||
* @property-read string $profile_photo_url
|
* @property-read string $profile_photo_url
|
||||||
@@ -109,7 +108,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
'is_placeholder' => 'boolean',
|
'is_placeholder' => 'boolean',
|
||||||
'week_start' => Weekday::class,
|
'week_start' => Weekday::class,
|
||||||
'send_time_entry_still_running_email' => 'boolean',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,7 +117,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
*/
|
*/
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'week_start' => Weekday::Monday,
|
'week_start' => Weekday::Monday,
|
||||||
'send_time_entry_still_running_email' => true,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<?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
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table): void {
|
|
||||||
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('send_time_entry_still_running_email');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -20,12 +20,6 @@ function profileInformationForm(page: Page) {
|
|||||||
.locator('xpath=ancestor::*[descendant::form][1]');
|
.locator('xpath=ancestor::*[descendant::form][1]');
|
||||||
}
|
}
|
||||||
|
|
||||||
function notificationSettingsForm(page: Page) {
|
|
||||||
return page
|
|
||||||
.getByRole('heading', { name: 'Notifications', exact: true })
|
|
||||||
.locator('xpath=ancestor::*[descendant::form][1]');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveProfileForm(page: Page): Promise<void> {
|
async function saveProfileForm(page: Page): Promise<void> {
|
||||||
const form = profileInformationForm(page);
|
const form = profileInformationForm(page);
|
||||||
await form.getByRole('button', { name: 'Save' }).click();
|
await form.getByRole('button', { name: 'Save' }).click();
|
||||||
@@ -56,22 +50,6 @@ test('week-start change persists across reload', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('still-running email notification setting persists across reload', async ({ page }) => {
|
|
||||||
await goToProfilePage(page);
|
|
||||||
const form = notificationSettingsForm(page);
|
|
||||||
const checkbox = form.getByLabel('Still-running time entry reminders');
|
|
||||||
|
|
||||||
await expect(checkbox).toBeChecked();
|
|
||||||
await checkbox.uncheck();
|
|
||||||
await form.getByRole('button', { name: 'Save' }).click();
|
|
||||||
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await expect(
|
|
||||||
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
|
|
||||||
).not.toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
|
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
|
||||||
FieldLabel,
|
|
||||||
} from '@/packages/ui/src/field';
|
|
||||||
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
|
|
||||||
import { useUpdateUserMutation, useUserQuery } from '@/utils/useUserQuery';
|
|
||||||
|
|
||||||
const { user } = useUserQuery();
|
|
||||||
const updateUser = useUpdateUserMutation();
|
|
||||||
|
|
||||||
const sendTimeEntryStillRunningEmail = ref(true);
|
|
||||||
const recentlySaved = ref(false);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
user,
|
|
||||||
(currentUser, previousUser) => {
|
|
||||||
if (currentUser && previousUser === undefined) {
|
|
||||||
sendTimeEntryStillRunningEmail.value = currentUser.send_time_entry_still_running_email;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const isUserLoaded = computed(() => user.value !== undefined);
|
|
||||||
const isSaveDisabled = computed(() => !isUserLoaded.value || updateUser.isPending.value);
|
|
||||||
const fieldErrors = computed<Record<string, string>>(() =>
|
|
||||||
getApiValidationFieldErrors(updateUser.error.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (isSaveDisabled.value || !user.value) return;
|
|
||||||
|
|
||||||
if (sendTimeEntryStillRunningEmail.value === user.value.send_time_entry_still_running_email) {
|
|
||||||
flashSaved();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedUser = await updateUser.mutateAsync({
|
|
||||||
userId: user.value.id,
|
|
||||||
body: {
|
|
||||||
send_time_entry_still_running_email: sendTimeEntryStillRunningEmail.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
sendTimeEntryStillRunningEmail.value = updatedUser.send_time_entry_still_running_email;
|
|
||||||
flashSaved();
|
|
||||||
} catch {
|
|
||||||
// 422: field errors render below. Other errors: toast handled in the mutation.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flashSaved() {
|
|
||||||
recentlySaved.value = true;
|
|
||||||
setTimeout(() => (recentlySaved.value = false), 2000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FormSection @submitted="save">
|
|
||||||
<template #title>Notifications</template>
|
|
||||||
|
|
||||||
<template #description>Choose which email notifications you want to receive.</template>
|
|
||||||
|
|
||||||
<template #form>
|
|
||||||
<div class="col-span-6 sm:col-span-4">
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<Checkbox
|
|
||||||
id="send_time_entry_still_running_email"
|
|
||||||
v-model:checked="sendTimeEntryStillRunningEmail"
|
|
||||||
:disabled="!isUserLoaded" />
|
|
||||||
<FieldContent>
|
|
||||||
<FieldLabel for="send_time_entry_still_running_email">
|
|
||||||
Still-running time entry reminders
|
|
||||||
</FieldLabel>
|
|
||||||
<FieldDescription>
|
|
||||||
Email me when a time entry has been running for more than 8 hours.
|
|
||||||
</FieldDescription>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<FieldError v-if="fieldErrors.send_time_entry_still_running_email">
|
|
||||||
{{ fieldErrors.send_time_entry_still_running_email }}
|
|
||||||
</FieldError>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<ActionMessage :on="recentlySaved" class="me-3">Saved.</ActionMessage>
|
|
||||||
|
|
||||||
<PrimaryButton :class="{ 'opacity-25': isSaveDisabled }" :disabled="isSaveDisabled">
|
|
||||||
Save
|
|
||||||
</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</FormSection>
|
|
||||||
</template>
|
|
||||||
@@ -9,7 +9,6 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
|
|||||||
import type { Session } from '@/types/jetstream';
|
import type { Session } from '@/types/jetstream';
|
||||||
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
|
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
|
||||||
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
|
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
|
||||||
import NotificationSettingsForm from '@/Pages/Profile/Partials/NotificationSettingsForm.vue';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
confirmsTwoFactorAuthentication: boolean;
|
confirmsTwoFactorAuthentication: boolean;
|
||||||
@@ -37,12 +36,6 @@ defineProps<{
|
|||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<NotificationSettingsForm />
|
|
||||||
|
|
||||||
<SectionBorder />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
||||||
|
|
||||||
|
|||||||
@@ -705,7 +705,6 @@ const UserResource = z
|
|||||||
profile_photo_url: z.string(),
|
profile_photo_url: z.string(),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
week_start: Weekday,
|
week_start: Weekday,
|
||||||
send_time_entry_still_running_email: z.boolean(),
|
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const UserUpdateRequest = z
|
const UserUpdateRequest = z
|
||||||
@@ -715,7 +714,6 @@ const UserUpdateRequest = z
|
|||||||
photo: z.union([z.string(), z.null()]),
|
photo: z.union([z.string(), z.null()]),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
week_start: Weekday,
|
week_start: Weekday,
|
||||||
send_time_entry_still_running_email: z.boolean(),
|
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|||||||
1
resources/js/types/models.d.ts
vendored
1
resources/js/types/models.d.ts
vendored
@@ -76,7 +76,6 @@ export interface User {
|
|||||||
two_factor_confirmed_at: string | null;
|
two_factor_confirmed_at: string | null;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
week_start: string;
|
week_start: string;
|
||||||
send_time_entry_still_running_email: boolean;
|
|
||||||
profile_photo_url: string;
|
profile_photo_url: string;
|
||||||
organizations: Organization[];
|
organizations: Organization[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
|||||||
@@ -159,26 +159,4 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame("Sending still running time entry emails...\n".
|
$this->assertSame("Sending still running time entry emails...\n".
|
||||||
"Finished sending 0 still running time entry emails...\n", $output);
|
"Finished sending 0 still running time entry emails...\n", $output);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_does_not_send_emails_to_users_who_disabled_them(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
$user->user->send_time_entry_still_running_email = false;
|
|
||||||
$user->user->save();
|
|
||||||
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
|
|
||||||
'start' => Carbon::now()->subHours(8)->subSecond(),
|
|
||||||
'end' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Mail::assertNothingOutgoing();
|
|
||||||
$this->assertNull($timeEntry->fresh()->still_active_email_sent_at);
|
|
||||||
$this->assertSame(Command::SUCCESS, $exitCode);
|
|
||||||
$this->assertSame("Sending still running time entry emails...\n".
|
|
||||||
"Finished sending 0 still running time entry emails...\n", Artisan::output());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'profile_photo_url' => $data->user->profile_photo_url,
|
'profile_photo_url' => $data->user->profile_photo_url,
|
||||||
'timezone' => $data->user->timezone,
|
'timezone' => $data->user->timezone,
|
||||||
'week_start' => $data->user->week_start->value,
|
'week_start' => $data->user->week_start->value,
|
||||||
'send_time_entry_still_running_email' => true,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonValidationErrors('organization_id');
|
$response->assertJsonValidationErrors('organization_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_changes_user_profile_settings(): void
|
public function test_update_changes_user_name_timezone_and_week_start(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission();
|
$data = $this->createUserWithPermission();
|
||||||
@@ -143,7 +142,6 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => 'Updated Name',
|
'name' => 'Updated Name',
|
||||||
'timezone' => 'America/New_York',
|
'timezone' => 'America/New_York',
|
||||||
'week_start' => Weekday::Sunday->value,
|
'week_start' => Weekday::Sunday->value,
|
||||||
'send_time_entry_still_running_email' => false,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -154,7 +152,6 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => 'Updated Name',
|
'name' => 'Updated Name',
|
||||||
'timezone' => 'America/New_York',
|
'timezone' => 'America/New_York',
|
||||||
'week_start' => Weekday::Sunday->value,
|
'week_start' => Weekday::Sunday->value,
|
||||||
'send_time_entry_still_running_email' => false,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -162,7 +159,6 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertSame('Updated Name', $user->name);
|
$this->assertSame('Updated Name', $user->name);
|
||||||
$this->assertSame('America/New_York', $user->timezone);
|
$this->assertSame('America/New_York', $user->timezone);
|
||||||
$this->assertSame(Weekday::Sunday, $user->week_start);
|
$this->assertSame(Weekday::Sunday, $user->week_start);
|
||||||
$this->assertFalse($user->send_time_entry_still_running_email);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_does_not_change_user_fields_that_are_not_given(): void
|
public function test_update_does_not_change_user_fields_that_are_not_given(): void
|
||||||
@@ -507,22 +503,6 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonValidationErrors(['week_start']);
|
$response->assertJsonValidationErrors(['week_start']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_fails_if_send_time_entry_still_running_email_is_not_boolean(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
|
||||||
'send_time_entry_still_running_email' => 'not-a-boolean',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertUnprocessable();
|
|
||||||
$response->assertJsonValidationErrors(['send_time_entry_still_running_email']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_fails_if_photo_is_not_a_string(): void
|
public function test_update_fails_if_photo_is_not_a_string(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
96
tests/Unit/Exceptions/Api/ApiExceptionTest.php
Normal file
96
tests/Unit/Exceptions/Api/ApiExceptionTest.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Exceptions\Api;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
|
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;
|
||||||
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
|
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||||
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
|
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||||
|
use App\Service\Export\ExportException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ApiExceptionTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{ApiException}>
|
||||||
|
*/
|
||||||
|
public static function expectedApiExceptionProvider(): iterable
|
||||||
|
{
|
||||||
|
yield CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::class => [new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers];
|
||||||
|
yield CanNotRemoveOwnerFromOrganization::class => [new CanNotRemoveOwnerFromOrganization];
|
||||||
|
yield ChangingRoleOfPlaceholderIsNotAllowed::class => [new ChangingRoleOfPlaceholderIsNotAllowed];
|
||||||
|
yield ChangingRoleToPlaceholderIsNotAllowed::class => [new ChangingRoleToPlaceholderIsNotAllowed];
|
||||||
|
yield EntityStillInUseApiException::class => [new EntityStillInUseApiException('member', 'time_entry')];
|
||||||
|
yield FeatureIsNotAvailableInFreePlanApiException::class => [new FeatureIsNotAvailableInFreePlanApiException];
|
||||||
|
yield InactiveUserCanNotBeUsedApiException::class => [new InactiveUserCanNotBeUsedApiException];
|
||||||
|
yield InvitationForTheEmailAlreadyExistsApiException::class => [new InvitationForTheEmailAlreadyExistsApiException];
|
||||||
|
yield OnlyOwnerCanChangeOwnership::class => [new OnlyOwnerCanChangeOwnership];
|
||||||
|
yield OnlyPlaceholdersCanBeMergedIntoAnotherMember::class => [new OnlyPlaceholdersCanBeMergedIntoAnotherMember];
|
||||||
|
yield OrganizationHasNoSubscriptionButMultipleMembersException::class => [new OrganizationHasNoSubscriptionButMultipleMembersException];
|
||||||
|
yield OrganizationNeedsAtLeastOneOwner::class => [new OrganizationNeedsAtLeastOneOwner];
|
||||||
|
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
|
||||||
|
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
|
||||||
|
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
|
||||||
|
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
|
||||||
|
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
|
||||||
|
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];
|
||||||
|
yield UserNotPlaceholderApiException::class => [new UserNotPlaceholderApiException];
|
||||||
|
yield UserResendEmailVerificationNoPendingEmailApiException::class => [new UserResendEmailVerificationNoPendingEmailApiException];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('expectedApiExceptionProvider')]
|
||||||
|
public function test_expected_api_exceptions_are_not_reported(ApiException $exception): void
|
||||||
|
{
|
||||||
|
// Arrange: exception supplied by the data provider
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$reportingHandled = $exception->report();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertTrue($reportingHandled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{ApiException}>
|
||||||
|
*/
|
||||||
|
public static function operationalApiExceptionProvider(): iterable
|
||||||
|
{
|
||||||
|
yield PdfRendererIsNotConfiguredException::class => [new PdfRendererIsNotConfiguredException];
|
||||||
|
yield PersonalAccessClientIsNotConfiguredException::class => [new PersonalAccessClientIsNotConfiguredException];
|
||||||
|
yield ExportException::class => [new ExportException];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('operationalApiExceptionProvider')]
|
||||||
|
public function test_operational_api_exceptions_are_reported(ApiException $exception): void
|
||||||
|
{
|
||||||
|
// Arrange: exception supplied by the data provider
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$reportingHandled = $exception->report();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertFalse($reportingHandled);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/Unit/Exceptions/HandlerTest.php
Normal file
40
tests/Unit/Exceptions/HandlerTest.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Exceptions;
|
||||||
|
|
||||||
|
use App\Exceptions\Handler;
|
||||||
|
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class HandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_oauth_access_denied_exceptions_are_not_reported(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$exception = OAuthServerException::accessDenied(
|
||||||
|
'Access token could not be verified',
|
||||||
|
previous: new RuntimeException('The token is expired')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$shouldReport = app(Handler::class)->shouldReport($exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertFalse($shouldReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_operational_oauth_exceptions_are_reported(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$exception = OAuthServerException::serverError('Signing key could not be read');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$shouldReport = app(Handler::class)->shouldReport($exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertTrue($shouldReport);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user