Compare commits

...

20 Commits

Author SHA1 Message Date
Gregor Vostrak
d626d16a9b fix activity graph border color 2026-09-03 17:05:29 +02:00
Gregor Vostrak
45c7377802 bump invoicing extension to v0.0.5 2026-08-31 16:38:25 +02:00
Gregor Vostrak
8e57275cef bump invoicing extension version 2026-08-31 16:03:26 +02:00
Gregor Vostrak
efc6b55628 add helper functions for tanstack form and cent conversions 2026-08-31 16:03:26 +02:00
Gregor Vostrak
c12789376d fix type error and adapt formatting to new prettier version changes 2026-08-31 16:03:26 +02:00
Gregor Vostrak
4795812b60 align alter dialog positioning with other modals 2026-08-31 16:03:26 +02:00
Gregor Vostrak
0e00979ab8 make sure all invoices routes show active state in the navigation 2026-08-31 16:03:26 +02:00
Gregor Vostrak
f1426fcb5e add combobox to ui package 2026-08-31 16:03:26 +02:00
Constantin Graf
23f512d4a4 Add permissions and types for invoice recipients 2026-08-31 16:03:26 +02:00
Constantin Graf
637475e669 Add invite-only registration mode 2026-08-31 13:36:25 +02:00
Constantin Graf
3ec2abb309 Add invite-only registration mode
Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage.
2026-08-31 13:36:25 +02:00
Gregor Vostrak
3e36b1cc01 replace TimezoneModalMismatch unit test with e2e test 2026-08-31 12:48:22 +02:00
Andrew Herron
7831bc697e Fix timezone mismatch modal posting to the removed Jetstream route
Use useUpdateUserMutation, matching UpdateProfileInformationForm.
2026-08-31 12:48:22 +02:00
Constantin Graf
602a8daa1f Suppress expected OAuth access denial reports 2026-08-31 12:42:00 +02:00
Constantin Graf
38b448a729 Suppress reporting for expected API exceptions 2026-08-31 12:42:00 +02:00
Constantin Graf
e6f071f87f Fixed formatting 2026-08-31 12:26:02 +02:00
Constantin Graf
693a1fa7e0 Test still-running email preference 2026-08-31 12:26:02 +02:00
Constantin Graf
77f14b696e Move email preference to notifications section 2026-08-31 12:26:02 +02:00
Constantin Graf
593372bae5 Add still-running email preference 2026-08-31 12:26:02 +02:00
dependabot[bot]
db9ca51fc4 Bump docker/login-action from 4 to 4.5.2
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 12:14:38 +02:00
91 changed files with 2562 additions and 1217 deletions

View File

@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
APP_ENABLE_REGISTRATION=on
SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -141,7 +141,7 @@ jobs:
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +195,7 @@ jobs:
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: rg.fr-par.scw.cloud/solidtime
username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\RegistrationMode;
use App\Enums\Weekday;
use App\Events\NewsletterRegistered;
use App\Models\User;
use App\Service\InvitationService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService;
use App\Service\UserService;
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
if (! config('app.enable_registration')) {
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
if ($registrationMode === RegistrationMode::Off) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [
$validated = Validator::make($input, [
'name' => [
'required',
'string',
@@ -60,6 +63,20 @@ class CreateNewUser implements CreatesNewUsers
],
])->validate();
if ($registrationMode === RegistrationMode::InviteOnly) {
$invitationService = app(InvitationService::class);
$email = (string) $validated['email'];
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
$message = $invitationService->hasPendingInvitationForEmail($email)
? __('Please accept the organization invitation sent to your email address before registering.')
: __('Registration is only available to invited users.');
throw ValidationException::withMessages([
'email' => [$message],
]);
}
}
$timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
if (app(TimezoneService::class)->isValid($input['timezone'])) {

View File

@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
])
->whereHas('user', function (Builder $query): void {
/** @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')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum RegistrationMode: string
{
case On = 'on';
case InviteOnly = 'invite-only';
case Off = 'off';
public static function fromConfig(mixed $value): self
{
if ($value === true) {
return self::On;
}
if ($value === false || $value === null) {
return self::Off;
}
return match (strtolower(trim((string) $value))) {
'1', 'on', 'true' => self::On,
'invite-only' => self::InviteOnly,
default => self::Off,
};
}
}

View File

@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
*/
public function report(): bool
{
// TODO: temporary activated
return false;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotRemoveOwnerFromOrganization extends ApiException
{
public const string KEY = 'can_not_remove_owner_from_organization';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
}

View File

@@ -19,6 +19,16 @@ class EntityStillInUseApiException extends ApiException
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.
*/

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{
public const string KEY = 'feature_is_not_available_in_free_plan';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InactiveUserCanNotBeUsedApiException extends ApiException
{
public const string KEY = 'inactive_user_can_not_be_used';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{
public const string KEY = 'invitation_for_the_email_already_exists';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException
{
public const string KEY = 'only_owner_can_change_ownership';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException
{
public const string KEY = 'organization_needs_at_least_one_owner';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException
{
public const string KEY = 'overlapping_time_entry';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException
{
public const string KEY = 'time_entry_can_not_be_restarted';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException
{
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;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_organization';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfProjectApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_project';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException
{
public const string KEY = 'user_not_placeholder';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{
public const string KEY = 'user_resend_email_verification_no_pending_email';
public function report(): bool
{
return true;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
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)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the

View File

@@ -124,6 +124,10 @@ class UserController extends Controller
$user->week_start = $request->getWeekStart();
}
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
}
$user->save();
if ($emailToVerify !== null) {

View File

@@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller
}
return redirect(route('register'))
->with('registration_email', $email)
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
'organization' => $organization->name,
]))

View File

@@ -58,6 +58,9 @@ class UserUpdateRequest extends BaseFormRequest
'week_start' => [
Rule::enum(Weekday::class),
],
'send_time_entry_still_running_email' => [
'boolean',
],
];
}
@@ -81,6 +84,13 @@ class UserUpdateRequest extends BaseFormRequest
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
{
return $this->has('photo');

View File

@@ -36,6 +36,8 @@ class UserResource extends BaseResource
'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */
'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,
];
}
}

View File

@@ -42,6 +42,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
* @property bool $send_time_entry_still_running_email
* @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization
* @property-read string $profile_photo_url
@@ -108,6 +109,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'is_admin' => 'boolean',
'is_placeholder' => 'boolean',
'week_start' => Weekday::class,
'send_time_entry_still_running_email' => 'boolean',
];
/**
@@ -117,6 +119,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/
protected $attributes = [
'week_start' => Weekday::Monday,
'send_time_entry_still_running_email' => true,
];
/**

View File

@@ -45,6 +45,7 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'email' => session('registration_email', ''),
'terms_url' => config('auth.terms_url'),
'privacy_policy_url' => config('auth.privacy_policy_url'),
'newsletter_consent' => config('auth.newsletter_consent'),

View File

@@ -18,6 +18,22 @@ use Illuminate\Support\Facades\Mail;
class InvitationService
{
public function hasAcceptedInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNotNull('accepted_at')
->exists();
}
public function hasPendingInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNull('accepted_at')
->exists();
}
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/

View File

@@ -80,6 +80,10 @@ class PermissionStore
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view',
'invoice-settings:update',
],
@@ -147,6 +151,10 @@ class PermissionStore
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view',
'invoice-settings:update',
],
@@ -203,6 +211,10 @@ class PermissionStore
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view',
'invoice-settings:update',
],

View File

@@ -100,7 +100,7 @@ return [
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),

View File

@@ -0,0 +1,24 @@
<?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');
});
}
};

View File

@@ -0,0 +1,32 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { getInvitationAcceptUrl } from './utils/mailpit';
test('prefills the registration email after accepting an invitation', async ({ page, browser }) => {
const memberId = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const memberEmail = `prefill-${memberId}@invitation.test`;
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await page.getByPlaceholder('Member Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
const inviteeContext = await browser.newContext();
const inviteePage = await inviteeContext.newPage();
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
await inviteePage.goto(acceptUrl);
await inviteePage.waitForURL(/\/register$/);
await expect(inviteePage.getByLabel('Email')).toHaveValue(memberEmail);
await inviteeContext.close();
});

View File

@@ -20,6 +20,12 @@ function profileInformationForm(page: Page) {
.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> {
const form = profileInformationForm(page);
await form.getByRole('button', { name: 'Save' }).click();
@@ -50,6 +56,22 @@ test('week-start change persists across reload', async ({ page }) => {
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 ({
page,
}) => {

View File

@@ -0,0 +1,89 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page, TestContext } from '../playwright/fixtures';
import { getCurrentUserViaApi, updateUserProfileViaApi } from './utils/api';
const BROWSER_TIMEZONE = 'Europe/Vienna';
const MISMATCHED_TIMEZONE = 'America/New_York';
test.use({ timezoneId: BROWSER_TIMEZONE });
function mismatchModal(page: Page) {
return page.getByRole('dialog').filter({ hasText: 'Timezone mismatch detected' });
}
async function openPageWithTimezoneMismatch(page: Page, ctx: TestContext) {
await updateUserProfileViaApi(ctx, { timezone: MISMATCHED_TIMEZONE });
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(mismatchModal(page)).toBeVisible();
}
test('timezone mismatch modal saves the device timezone through the users API', async ({
page,
ctx,
}) => {
await openPageWithTimezoneMismatch(page, ctx);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(),
]);
await expect(mismatchModal(page)).toBeHidden();
const user = await getCurrentUserViaApi(ctx);
expect(user.timezone).toBe(BROWSER_TIMEZONE);
// After the automatic reload the timezones match again, so the modal stays gone.
await page.waitForLoadState('load');
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(mismatchModal(page)).toBeHidden();
});
test('timezone mismatch modal does not open for a different timezone with the same time', async ({
page,
ctx,
}) => {
// Berlin and Vienna share the same offset and DST rules, so the times match.
await updateUserProfileViaApi(ctx, { timezone: 'Europe/Berlin' });
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
// Wait until the dashboard is rendered so the modal had its chance to mount.
await expect(page.getByTestId('dashboard_view')).toBeVisible();
await expect(mismatchModal(page)).toBeHidden();
});
test('timezone mismatch modal stays open when the update fails', async ({ page, ctx }) => {
await openPageWithTimezoneMismatch(page, ctx);
await page.route('**/api/v1/users/*', (route) => {
if (route.request().method() === 'PUT') {
return route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Server error' }),
});
}
return route.fallback();
});
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 500
),
mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(),
]);
await expect(mismatchModal(page)).toBeVisible();
await expect(page.getByText('Failed to update profile')).toBeVisible();
const user = await getCurrentUserViaApi(ctx);
expect(user.timezone).toBe(MISMATCHED_TIMEZONE);
});

View File

@@ -9,6 +9,6 @@
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.2"
"ref": "v0.0.5"
}
}

2193
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@
"@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11",
"@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.0",
"@lucide/vue": "^1.28.0",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.32.0",
@@ -67,7 +67,7 @@
"@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vueuse/core": "^14.3.0",
"@vueuse/core": "^14.4.0",
"@vueuse/integrations": "^14.3.0",
"@zodios/core": "^10.9.6",
"chroma-js": "^3.2.0",
@@ -79,7 +79,7 @@
"parse-duration": "^2.1.6",
"pinia": "^3.0.4",
"radix-vue": "^1.9.17",
"reka-ui": "^2.9.7",
"reka-ui": "^2.10.1",
"tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1",

View File

@@ -10,13 +10,7 @@ import Pagination from '@/Components/Common/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =
| 'name'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
import { useUpdateUserMutation } from '@/utils/useUserQuery';
const show = defineModel('show', { default: false });
const saving = ref(false);
const page = usePage<{
auth: {
@@ -13,33 +12,28 @@ const page = usePage<{
};
}>();
function handleUpdate(timezone: string) {
saving.value = true;
const form = useForm({
_method: 'PUT',
timezone: timezone,
name: page.props.auth.user.name,
email: page.props.auth.user.email,
week_start: page.props.auth.user.week_start,
});
const updateUser = useUpdateUserMutation();
form.post(route('user-profile-information.update'), {
errorBag: 'updateProfileInformation',
preserveScroll: true,
onSuccess: () => {
saving.value = false;
show.value = false;
location.reload();
},
onError: () => {
saving.value = false;
},
});
async function handleUpdate(timezone: string) {
try {
await updateUser.mutateAsync({
userId: page.props.auth.user.id,
body: { timezone },
});
show.value = false;
// reload the whole page to re-read the timezone update
location.reload();
} catch {
// notification handled by mutation
}
}
</script>
<template>
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
<TimezoneMismatchModal
v-model:show="show"
:saving="updateUser.isPending.value"
@update="handleUpdate" />
</template>
<style scoped></style>

View File

@@ -63,7 +63,7 @@ const max = computed(() => {
});
const backgroundColor = useCssVariable('--theme-color-card-background');
const borderColor = useCssVariable('--color-border');
const borderColor = useCssVariable('--color-border-secondary');
const labelColor = useCssVariable('--color-text-secondary');
const chartColorRaw = useCssVariable('--theme-color-chart');

View File

@@ -146,7 +146,10 @@ const changeSummary = computed<PlanLine[]>(() => {
{ times: range(plan.breakSlot), label: 'Break' },
{ times: range(plan.secondHalf), label: workLabel },
...plan.shifted.map((shift) => ({
times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift),
times: moved(
req.otherEntries.find((e) => e.id === shift.id)!,
shift
),
label: props.entryLabel(shift.id),
})),
];

View File

@@ -161,7 +161,7 @@ const emit = defineEmits<{
:organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null"
align="start"
@changed="(p, t) => emit('add-row', p, t)">
@changed="(p: string | null, t: string | null) => emit('add-row', p, t)">
<template #trigger>
<Button variant="ghost" size="sm" class="text-text-secondary">
<PlusIcon class="h-4 w-4 mr-1 text-icon-default" />

View File

@@ -25,16 +25,21 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class
)
">
<slot />
</AlertDialogContent>
class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
<div class="absolute inset-0 bg-default-background opacity-30" />
</AlertDialogOverlay>
<div
class="fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto">
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'pointer-events-auto bg-default-background grid w-full max-w-lg gap-4 border border-border-tertiary p-6 shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
">
<slot />
</AlertDialogContent>
</div>
</AlertDialogPortal>
</template>

View File

@@ -253,7 +253,9 @@ const page = usePage<{
v-if="isInvoicingActivated() && canViewInvoices()"
title="Invoices"
:icon="DocumentTextIcon"
:current="route().current('invoices')"
:current="
route().current('invoices') || route().current('invoices.*')
"
href="/invoices"></NavigationSidebarItem>
</ul>
</nav>

View File

@@ -8,9 +8,13 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
const props = defineProps<{
email: string;
}>();
const form = useForm({
name: '',
email: '',
email: props.email,
password: '',
password_confirmation: '',
terms: false,

View File

@@ -35,8 +35,7 @@ async function deleteUser() {
} catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
{ status?: number; data?: { errors?: { password?: string[] } } } | undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}

View File

@@ -0,0 +1,103 @@
<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>

View File

@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
import type { Session } from '@/types/jetstream';
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
import NotificationSettingsForm from '@/Pages/Profile/Partials/NotificationSettingsForm.vue';
defineProps<{
confirmsTwoFactorAuthentication: boolean;
@@ -36,6 +37,12 @@ defineProps<{
<SectionBorder />
</div>
<div>
<NotificationSettingsForm />
<SectionBorder />
</div>
<div>
<UpdatePasswordForm class="mt-10 sm:mt-0" />

View File

@@ -38,8 +38,7 @@ const deleteTeam = async () => {
} catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
{ status?: number; data?: { errors?: { password?: string[] } } } | undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}

View File

@@ -118,6 +118,11 @@ export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getIn
export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type InvoiceRecipient = ZodiosResponseByAlias<
SolidTimeApi,
'getInvoiceRecipients'
>['data'][0];
export type InvoiceRecipientBody = ZodiosBodyByAlias<SolidTimeApi, 'createInvoiceRecipient'>;
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;

View File

@@ -45,14 +45,54 @@ const InvitationResource = z
const InvitationStoreRequest = z
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
.passthrough();
const InvoiceRecipientResource = z
.object({
id: z.string(),
organization_id: z.string(),
name: z.string(),
vatin: z.union([z.string(), z.null()]),
address_line_1: z.union([z.string(), z.null()]),
address_line_2: z.union([z.string(), z.null()]),
address_line_3: z.union([z.string(), z.null()]),
address_post_code: z.union([z.string(), z.null()]),
address_city: z.union([z.string(), z.null()]),
address_country: z.union([z.string(), z.null()]),
phone: z.union([z.string(), z.null()]),
email: z.union([z.string(), z.null()]),
is_archived: z.boolean(),
archived_at: z.union([z.string(), z.null()]),
invoices_count: z.number().int(),
has_non_draft_invoices: z.boolean(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
})
.passthrough();
const InvoiceRecipientCollection = z.array(InvoiceRecipientResource);
const InvoiceRecipientRequest = z
.object({
name: z.string(),
vatin: z.union([z.string(), z.null()]).optional(),
address_line_1: z.union([z.string(), z.null()]).optional(),
address_line_2: z.union([z.string(), z.null()]).optional(),
address_line_3: z.union([z.string(), z.null()]).optional(),
address_post_code: z.union([z.string(), z.null()]).optional(),
address_city: z.union([z.string(), z.null()]).optional(),
address_country: z.union([z.string(), z.null()]).optional(),
phone: z.union([z.string(), z.null()]).optional(),
email: z.union([z.string(), z.null()]).optional(),
is_archived: z.boolean().optional(),
})
.passthrough();
const InvoiceResource = z
.object({
id: z.string(),
organization_id: z.string(),
invoice_recipient_id: z.string(),
reference: z.string(),
seller_name: z.string(),
buyer_name: z.string(),
recipient: z.string(),
status: z.string(),
status_label: z.string(),
date: z.string(),
due_at: z.string(),
paid_date: z.string(),
@@ -76,16 +116,7 @@ const InvoiceStoreRequest = z
seller_address_country: z.union([z.string(), z.null()]).optional(),
seller_phone: z.union([z.string(), z.null()]).optional(),
seller_email: z.union([z.string(), z.null()]).optional(),
buyer_name: z.string(),
buyer_vatin: z.union([z.string(), z.null()]).optional(),
buyer_address_line_1: z.union([z.string(), z.null()]).optional(),
buyer_address_line_2: z.union([z.string(), z.null()]).optional(),
buyer_address_line_3: z.union([z.string(), z.null()]).optional(),
buyer_address_post_code: z.union([z.string(), z.null()]).optional(),
buyer_address_city: z.union([z.string(), z.null()]).optional(),
buyer_address_country: z.union([z.string(), z.null()]).optional(),
buyer_phone: z.union([z.string(), z.null()]).optional(),
buyer_email: z.union([z.string(), z.null()]).optional(),
invoice_recipient_id: z.string(),
date: z.string(),
billing_period_start: z.union([z.string(), z.null()]).optional(),
billing_period_end: z.union([z.string(), z.null()]).optional(),
@@ -130,6 +161,7 @@ const DetailedInvoiceResource = z
.object({
id: z.string(),
organization_id: z.string(),
invoice_recipient_id: z.string(),
reference: z.string(),
seller_name: z.string(),
seller_vatin: z.string(),
@@ -141,16 +173,7 @@ const DetailedInvoiceResource = z
seller_address_country: z.string(),
seller_phone: z.string(),
seller_email: z.string(),
buyer_name: z.string(),
buyer_vatin: z.string(),
buyer_address_line_1: z.string(),
buyer_address_line_2: z.string(),
buyer_address_line_3: z.string(),
buyer_address_post_code: z.string(),
buyer_address_city: z.string(),
buyer_address_country: z.string(),
buyer_phone: z.string(),
buyer_email: z.string(),
recipient: InvoiceRecipientResource,
paid_date: z.string(),
due_at: z.string(),
discount_type: z.string(),
@@ -171,7 +194,7 @@ const DetailedInvoiceResource = z
entries: z.array(InvoiceEntryResource),
})
.passthrough();
const InvoiceStatus = z.enum(['draft', 'sent', 'cancelled']);
const InvoiceStatus = z.enum(['draft', 'sent', 'paid', 'cancelled']);
const InvoiceUpdateRequest = z
.object({
status: InvoiceStatus,
@@ -187,16 +210,7 @@ const InvoiceUpdateRequest = z
seller_address_country: z.union([z.string(), z.null()]),
seller_phone: z.union([z.string(), z.null()]),
seller_email: z.union([z.string(), z.null()]),
buyer_name: z.string(),
buyer_vatin: z.union([z.string(), z.null()]),
buyer_address_line_1: z.union([z.string(), z.null()]),
buyer_address_line_2: z.union([z.string(), z.null()]),
buyer_address_line_3: z.union([z.string(), z.null()]),
buyer_address_post_code: z.union([z.string(), z.null()]),
buyer_address_city: z.union([z.string(), z.null()]),
buyer_address_country: z.union([z.string(), z.null()]),
buyer_phone: z.union([z.string(), z.null()]),
buyer_email: z.union([z.string(), z.null()]),
invoice_recipient_id: z.string(),
date: z.string(),
billing_period_start: z.union([z.string(), z.null()]),
billing_period_end: z.union([z.string(), z.null()]),
@@ -705,6 +719,7 @@ const UserResource = z
profile_photo_url: z.string(),
timezone: z.string(),
week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
})
.passthrough();
const UserUpdateRequest = z
@@ -714,6 +729,7 @@ const UserUpdateRequest = z
photo: z.union([z.string(), z.null()]),
timezone: z.string(),
week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
})
.partial()
.passthrough();
@@ -1895,6 +1911,125 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/invoice-recipients',
alias: 'getInvoiceRecipients',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientCollection }).passthrough(),
},
{
method: 'post',
path: '/v1/organizations/:organization/invoice-recipients',
alias: 'createInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'get',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'getInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'put',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'updateInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'post',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient/duplicate',
alias: 'duplicateInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'delete',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'deleteInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
},
{
method: 'get',
path: '/v1/organizations/:organization/invoices',
@@ -1911,6 +2046,11 @@ const endpoints = makeApi([
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
{
name: 'status',
type: 'Query',
schema: InvoiceStatus.optional(),
},
],
response: z.object({ data: InvoiceCollection }).passthrough(),
errors: [

View File

@@ -8,7 +8,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
const active = defineModel({ default: false });
const active = defineModel<boolean>({ default: false });
const emit = defineEmits(['changed']);
function toggleBillable() {
active.value = !active.value;

View File

@@ -16,7 +16,7 @@ const props = withDefaults(
);
const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false });
const open = defineModel<boolean>({ default: false });
function handleAutofocus(event: Event) {
if (props.autoFocus === false) {

View File

@@ -13,11 +13,11 @@ import { type ComputedRef } from 'vue';
const temporaryCustomTimerEntry = ref<string>('');
const start = defineModel('start', {
const start = defineModel<string>('start', {
default: '',
});
const end = defineModel('end', {
const end = defineModel<string>('end', {
default: '',
});

View File

@@ -20,7 +20,7 @@ const NONE_ID = 'none';
const ROW_HEIGHT = 32;
const model = defineModel<string[]>({
default: [],
default: () => [],
});
const props = defineProps<{

View File

@@ -32,7 +32,7 @@ const props = withDefaults(
);
const model = defineModel<string[]>({
default: [],
default: () => [],
});
const open = ref(false);

View File

@@ -21,7 +21,7 @@ import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [],
default: () => [],
});
const props = withDefaults(

View File

@@ -20,7 +20,7 @@ const emit = defineEmits<{
}>();
const model = defineModel<string[]>({
default: [],
default: () => [],
});
const timeEntryTags = computed<Tag[]>(() => {

View File

@@ -10,7 +10,7 @@ const emit = defineEmits<{
}>();
const model = defineModel<string[]>({
default: [],
default: () => [],
});
const iconColorClasses = computed(() => {
if (model.value.length > 0) {

View File

@@ -7,7 +7,7 @@ import { getUserTimezone } from './utils/settings';
import { getDayJsInstance } from './utils/time';
import { useSessionStorage } from '@vueuse/core';
const show = defineModel('show', { default: false });
const show = defineModel<boolean>('show', { default: false });
const emit = defineEmits<{
update: [timezone: string];

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ComboboxRootEmits, ComboboxRootProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxRootProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxRootEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<!-- Keep the trigger inside this root. Reka treats anything within the root
element as "not outside", so it suppresses its own dismissal for trigger
clicks and ComboboxInput's blur-close ignores them. Putting the trigger
in a separate Popover instead gives two competing open states, and the
popover then closes on mousedown and reopens on the following click. -->
<ComboboxRoot v-slot="slotProps" v-bind="forwarded" :class="cn('min-w-0', props.class)">
<slot v-bind="slotProps" />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxAnchorProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxAnchor, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxAnchor v-bind="forwarded" :class="cn('w-full', props.class)">
<slot />
</ComboboxAnchor>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { ComboboxInputEmits, ComboboxInputProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { Search } from '@lucide/vue';
import { ComboboxInput, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
defineOptions({
inheritAttrs: false,
});
const props = defineProps<ComboboxInputProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxInputEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<div class="relative items-center border-b border-card-background-separator">
<ComboboxInput
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'h-10 w-full border-0 rounded-none bg-transparent pl-9 pr-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-0',
props.class
)
" />
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-3">
<Search class="size-4 text-text-tertiary" />
</span>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxItemEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="
cn(
'flex w-full cursor-default items-center rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active',
props.class
)
">
<slot />
</ComboboxItem>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'popper',
align: 'start',
sideOffset: 4,
class: undefined,
}
);
const emits = defineEmits<ComboboxContentEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxPortal>
<ComboboxContent
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'z-50 w-[--reka-popper-anchor-width] min-w-60 overflow-hidden rounded-lg border border-popover-border bg-popover text-popover-foreground shadow-dropdown outline-none origin-[var(--reka-combobox-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
">
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxSeparatorProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxSeparator, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxSeparator
v-bind="forwarded"
:class="cn('my-1 h-px bg-card-background-separator', props.class)" />
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ComboboxTriggerProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxTrigger, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<!-- Reka hardcodes tabindex="-1" here because it expects a ComboboxInput
next to the trigger in the anchor to be the focusable control. Our input
lives inside ComboboxList, so this trigger is the control and has to be
tabbable. tabindex is a fallthrough attr, which Vue applies after Reka's
own props and therefore wins.
Reka also hardcodes aria-label="Show popup", which would otherwise be
the accessible name. Pass :aria-label on the child element (it wins
again, because Slot merges child props over attrs) to name the trigger
after its current value. -->
<ComboboxTrigger v-bind="forwarded" :class="cn(props.class)" tabindex="0">
<slot />
</ComboboxTrigger>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ComboboxViewportProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxViewport, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxViewportProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxViewport
v-bind="forwarded"
:class="cn('ui-combobox-viewport max-h-60 overflow-y-auto p-2', props.class)">
<slot />
</ComboboxViewport>
</template>
<style>
/* Reka's ComboboxViewport injects a global style that hides scrollbars; the
attribute+class selector below is more specific and restores them. */
[data-reka-combobox-viewport].ui-combobox-viewport {
scrollbar-width: thin;
-ms-overflow-style: auto;
}
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar {
display: block;
width: 8px;
}
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar-thumb {
background-color: rgb(127 127 127 / 0.4);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,9 @@
export { default as Combobox } from './Combobox.vue';
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
export { default as ComboboxInput } from './ComboboxInput.vue';
export { default as ComboboxItem } from './ComboboxItem.vue';
export { default as ComboboxList } from './ComboboxList.vue';
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
export { default as ComboboxViewport } from './ComboboxViewport.vue';
export { ComboboxVirtualizer } from 'reka-ui';

View File

@@ -57,6 +57,16 @@ import {
CalendarNextButton,
CalendarPrevButton,
} from './calendar/index';
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
} from './combobox/index';
import { CommandPalette } from './CommandPalette/index';
import {
ContextMenu,
@@ -176,6 +186,14 @@ export {
CardTitle,
Checkbox,
color,
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
CommandPalette,
ContextMenu,
ContextMenuCheckboxItem,

View File

@@ -43,6 +43,13 @@ export function formatCents(
return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat);
}
/*
* Converts a major-unit amount (e.g. 19.99) to integer hundredths (i.e. cents)
*/
export function toCents(value: number | undefined | null): number {
return Math.round((value || 0) * 100);
}
export function getOrganizationCurrencySymbol(currency: string) {
return (0)
.toLocaleString('de-DE', {

View File

@@ -1,9 +1,5 @@
export type NumberFormat =
| 'point-comma'
| 'comma-point'
| 'space-comma'
| 'space-point'
| 'apostrophe-point';
'point-comma' | 'comma-point' | 'space-comma' | 'space-point' | 'apostrophe-point';
/**
* Formats a number according to the specified format

View File

@@ -76,6 +76,7 @@ export interface User {
two_factor_confirmed_at: string | null;
timezone: string;
week_start: string;
send_time_entry_still_running_email: boolean;
profile_photo_url: string;
organizations: Organization[];
clients: Client[];

View File

@@ -23,6 +23,21 @@ export function getApiValidationFieldErrors(error: unknown): Record<string, stri
return fieldErrors;
}
/*
* Like getApiValidationFieldErrors, but with Laravel's dot-notation array
* indices (entries.0.unit_price) converted to the bracket notation of
* TanStack Form field names (entries[0].unit_price), so that the errors
* attach to the mounted fields.
*/
export function getApiValidationFormFieldErrors(error: unknown): Record<string, string> {
return Object.fromEntries(
Object.entries(getApiValidationFieldErrors(error)).map(([field, message]) => [
field.replace(/\.(\d+)(?=\.|$)/g, '[$1]'),
message,
])
);
}
export function getApiValidationMessage(error: unknown, fallback: string): string {
if (!isApiValidationError(error)) {
return fallback;

View File

@@ -27,13 +27,7 @@ import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import type { Organization } from '@/types/models';
export type CommandGroup =
| 'timer'
| 'active-timer'
| 'navigation'
| 'create'
| 'theme'
| 'organization'
| 'entity';
'timer' | 'active-timer' | 'navigation' | 'create' | 'theme' | 'organization' | 'entity';
export interface Command {
id: string;

View File

@@ -385,21 +385,19 @@ export function useCommandPalette() {
const matching = projects.value
.filter((p: Project) => p.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(p: Project): EntitySearchResult => ({
id: `entity-project-${p.id}`,
label: p.name,
icon: ENTITY_ICONS.project,
keywords: ['project'],
action: () => {
closePaletteAfterAction();
router.visit(route('projects.show', { project: p.id }));
},
entityType: 'project',
color: p.color,
badgeClass: ENTITY_BADGE_CLASSES.project,
})
);
.map((p: Project): EntitySearchResult => ({
id: `entity-project-${p.id}`,
label: p.name,
icon: ENTITY_ICONS.project,
keywords: ['project'],
action: () => {
closePaletteAfterAction();
router.visit(route('projects.show', { project: p.id }));
},
entityType: 'project',
color: p.color,
badgeClass: ENTITY_BADGE_CLASSES.project,
}));
results.push(...matching);
}
@@ -407,20 +405,18 @@ export function useCommandPalette() {
const matching = clients.value
.filter((c: Client) => c.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(c: Client): EntitySearchResult => ({
id: `entity-client-${c.id}`,
label: c.name,
icon: ENTITY_ICONS.client,
keywords: ['client'],
action: () => {
closePaletteAfterAction();
router.visit(route('clients'));
},
entityType: 'client',
badgeClass: ENTITY_BADGE_CLASSES.client,
})
);
.map((c: Client): EntitySearchResult => ({
id: `entity-client-${c.id}`,
label: c.name,
icon: ENTITY_ICONS.client,
keywords: ['client'],
action: () => {
closePaletteAfterAction();
router.visit(route('clients'));
},
entityType: 'client',
badgeClass: ENTITY_BADGE_CLASSES.client,
}));
results.push(...matching);
}
@@ -428,22 +424,20 @@ export function useCommandPalette() {
const matching = tasks.value
.filter((t: Task) => t.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(t: Task): EntitySearchResult => ({
id: `entity-task-${t.id}`,
label: t.name,
icon: ENTITY_ICONS.task,
keywords: ['task'],
action: () => {
closePaletteAfterAction();
if (t.project_id) {
router.visit(route('projects.show', { project: t.project_id }));
}
},
entityType: 'task',
badgeClass: ENTITY_BADGE_CLASSES.task,
})
);
.map((t: Task): EntitySearchResult => ({
id: `entity-task-${t.id}`,
label: t.name,
icon: ENTITY_ICONS.task,
keywords: ['task'],
action: () => {
closePaletteAfterAction();
if (t.project_id) {
router.visit(route('projects.show', { project: t.project_id }));
}
},
entityType: 'task',
badgeClass: ENTITY_BADGE_CLASSES.task,
}));
results.push(...matching);
}
@@ -451,20 +445,18 @@ export function useCommandPalette() {
const matching = tags.value
.filter((t: Tag) => t.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(t: Tag): EntitySearchResult => ({
id: `entity-tag-${t.id}`,
label: t.name,
icon: ENTITY_ICONS.tag,
keywords: ['tag'],
action: () => {
closePaletteAfterAction();
router.visit(route('tags'));
},
entityType: 'tag',
badgeClass: ENTITY_BADGE_CLASSES.tag,
})
);
.map((t: Tag): EntitySearchResult => ({
id: `entity-tag-${t.id}`,
label: t.name,
icon: ENTITY_ICONS.tag,
keywords: ['tag'],
action: () => {
closePaletteAfterAction();
router.visit(route('tags'));
},
entityType: 'tag',
badgeClass: ENTITY_BADGE_CLASSES.tag,
}));
results.push(...matching);
}
@@ -472,20 +464,18 @@ export function useCommandPalette() {
const matching = members.value
.filter((m: Member) => m.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(m: Member): EntitySearchResult => ({
id: `entity-member-${m.id}`,
label: m.name,
icon: ENTITY_ICONS.member,
keywords: ['member'],
action: () => {
closePaletteAfterAction();
router.visit(route('members'));
},
entityType: 'member',
badgeClass: ENTITY_BADGE_CLASSES.member,
})
);
.map((m: Member): EntitySearchResult => ({
id: `entity-member-${m.id}`,
label: m.name,
icon: ENTITY_ICONS.member,
keywords: ['member'],
action: () => {
closePaletteAfterAction();
router.visit(route('members'));
},
entityType: 'member',
badgeClass: ENTITY_BADGE_CLASSES.member,
}));
results.push(...matching);
}

View File

@@ -22,6 +22,13 @@ use TiMacDonald\Log\LogEntry;
class RegistrationTest extends TestCaseWithDatabase
{
protected function setUp(): void
{
parent::setUp();
Config::set('app.enable_registration', 'on');
}
public function test_registration_screen_can_be_rendered(): void
{
if (! Features::enabled(Features::registration())) {
@@ -89,6 +96,77 @@ class RegistrationTest extends TestCaseWithDatabase
Event::assertNotDispatched(NewsletterRegistered::class);
}
public function test_user_registration_fails_without_an_invitation_if_registration_is_invite_only(): void
{
Config::set('app.enable_registration', 'invite-only');
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
$response->assertInvalid([
'email' => 'Registration is only available to invited users.',
]);
$this->assertFalse(User::query()->where('email', 'test@example.com')->exists());
}
public function test_invited_user_can_register_if_registration_is_invite_only(): void
{
Config::set('app.enable_registration', 'invite-only');
$user = $this->createUserWithPermission();
OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'Invited.User@example.com',
]);
$response = $this->post('/register', [
'name' => 'Invited User',
'email' => 'invited.user@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
$response->assertValid();
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
$newUser = User::query()->where('email', 'invited.user@example.com')->firstOrFail();
$this->assertSame($user->organization->getKey(), $newUser->organizations()->firstOrFail()->getKey());
}
public function test_user_must_accept_pending_invitation_before_registration_if_registration_is_invite_only(): void
{
Config::set('app.enable_registration', 'invite-only');
$user = $this->createUserWithPermission();
OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->create([
'email' => 'test@example.com',
]);
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
$response->assertInvalid([
'email' => 'Please accept the organization invitation sent to your email address before registering.',
]);
$this->assertGuest();
$this->assertFalse(User::query()->where('email', 'test@example.com')->exists());
}
public function test_new_user_can_not_register_with_likely_invalid_domain(): void
{
// Act

View File

@@ -159,4 +159,26 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
$this->assertSame("Sending still running time entry emails...\n".
"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());
}
}

View File

@@ -45,6 +45,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'profile_photo_url' => $data->user->profile_photo_url,
'timezone' => $data->user->timezone,
'week_start' => $data->user->week_start->value,
'send_time_entry_still_running_email' => true,
],
]);
}
@@ -131,7 +132,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors('organization_id');
}
public function test_update_changes_user_name_timezone_and_week_start(): void
public function test_update_changes_user_profile_settings(): void
{
// Arrange
$data = $this->createUserWithPermission();
@@ -142,6 +143,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name',
'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
]);
// Assert
@@ -152,6 +154,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name',
'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
],
]);
@@ -159,6 +162,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$this->assertSame('Updated Name', $user->name);
$this->assertSame('America/New_York', $user->timezone);
$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
@@ -503,6 +507,22 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$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
{
// Arrange

View File

@@ -38,6 +38,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$response->assertSessionHas('registration_email', strtolower($invitation->email));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}
@@ -64,6 +65,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$response->assertSessionHas('registration_email', strtolower($invitation->email));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Enums;
use App\Enums\RegistrationMode;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(RegistrationMode::class)]
class RegistrationModeTest extends TestCase
{
/**
* @return array<string, array{mixed, RegistrationMode}>
*/
public static function configValueProvider(): array
{
return [
'boolean true' => [true, RegistrationMode::On],
'on' => ['on', RegistrationMode::On],
'true' => ['true', RegistrationMode::On],
'invite-only' => ['invite-only', RegistrationMode::InviteOnly],
'boolean false' => [false, RegistrationMode::Off],
'off' => ['off', RegistrationMode::Off],
'false' => ['false', RegistrationMode::Off],
'unsupported invite alias' => ['invite', RegistrationMode::Off],
'unknown' => ['unknown', RegistrationMode::Off],
];
}
#[DataProvider('configValueProvider')]
public function test_registration_mode_is_created_from_config_value(mixed $value, RegistrationMode $expected): void
{
$this->assertSame($expected, RegistrationMode::fromConfig($value));
}
}

View 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);
}
}

View 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);
}
}