Compare commits

..

3 Commits

Author SHA1 Message Date
Constantin Graf
9f8f19e170 Fixed code formatting 2026-08-25 13:32:16 +02:00
Constantin Graf
d40e6a0d52 Bump billing extension to v0.0.5 2026-08-25 13:22:37 +02:00
Constantin Graf
1cee7581f8 Updated service extension 2026-08-25 12:57:00 +02:00
170 changed files with 1998 additions and 5057 deletions

View File

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

1
.github/VOUCHED.td vendored
View File

@@ -14,7 +14,6 @@
# Seeded 2026-07-25 from the authors of every merged pull request. # Seeded 2026-07-25 from the authors of every merged pull request.
agross agross
akolenda
bufferhead-code bufferhead-code
candideu candideu
kasparrosin kasparrosin

View File

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

View File

@@ -97,7 +97,7 @@ jobs:
- name: "Install dependencies in billing extension" - name: "Install dependencies in billing extension"
uses: php-actions/composer@v6 uses: php-actions/composer@v6
env: env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}' COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
with: with:
working_dir: "extensions/Billing" working_dir: "extensions/Billing"
command: install command: install
@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.5.2 uses: docker/login-action@v4
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

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

View File

@@ -1,136 +0,0 @@
name: PHPUnit Tests - Extensions
on: push
permissions:
contents: read
jobs:
phpunit-extensions:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: pcov
- uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Activate billing extension"
run: php artisan module:enable Billing
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
- name: "Run PHPUnit"
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure

View File

@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\RegistrationMode;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\User; use App\Models\User;
use App\Service\InvitationService;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService; use App\Service\UserService;
@@ -33,14 +31,13 @@ class CreateNewUser implements CreatesNewUsers
*/ */
public function create(array $input): User public function create(array $input): User
{ {
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration')); if (! config('app.enable_registration')) {
if ($registrationMode === RegistrationMode::Off) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')], 'email' => [__('Registration is disabled.')],
]); ]);
} }
$validated = Validator::make($input, [ Validator::make($input, [
'name' => [ 'name' => [
'required', 'required',
'string', 'string',
@@ -63,20 +60,6 @@ class CreateNewUser implements CreatesNewUsers
], ],
])->validate(); ])->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; $timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) { if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
if (app(TimezoneService::class)->isValid($input['timezone'])) { if (app(TimezoneService::class)->isValid($input['timezone'])) {

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* User provider that only resolves non-placeholder users.
*
* Placeholder users are created by imports and when members are removed from an
* organization. They can share an email address with a real user, so resolving a user by
* email can return a placeholder instead of the real account. The login flow filters them
* out explicitly, but the password broker and the guard credential checks (for example the
* password confirmation) resolve users through the configured user provider.
*
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
* built-in one for every provider in config/auth.php.
*/
class ActiveUserProvider extends EloquentUserProvider
{
/**
* @param Model|null $model
* @return Builder<Model>
*/
#[\Override]
protected function newModelQuery($model = null): Builder
{
$query = parent::newModelQuery($model);
$query->getQuery()->where('is_placeholder', '=', false);
return $query;
}
}

View File

@@ -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 {

View File

@@ -1,29 +0,0 @@
<?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,6 +60,7 @@ abstract class ApiException extends Exception
*/ */
public function report(): bool public function report(): bool
{ {
// TODO: temporary activated
return false; return false;
} }
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

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

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,14 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,9 +7,4 @@ 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;
}
} }

View File

@@ -7,7 +7,6 @@ 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;
@@ -35,10 +34,6 @@ 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

View File

@@ -28,9 +28,7 @@ class ExportController extends Controller
$filepath = $exportService->export($organization); $filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private')) $downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [ ->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
]);
return new JsonResponse([ return new JsonResponse([
'success' => true, 'success' => true,

View File

@@ -336,9 +336,7 @@ class TimeEntryController extends Controller
return response()->json([ return response()->json([
'download_url' => Storage::disk(config('filesystems.private')) 'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5), [ ->temporaryUrl($path, now()->addMinutes(5)),
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]); ]);
} }
@@ -549,9 +547,7 @@ class TimeEntryController extends Controller
return response()->json([ return response()->json([
'download_url' => Storage::disk(config('filesystems.private')) 'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5), [ ->temporaryUrl($path, now()->addMinutes(5)),
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]); ]);
} }

View File

@@ -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) {

View File

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

View File

@@ -24,7 +24,6 @@ class ImportRequest extends BaseFormRequest
'data' => [ 'data' => [
'required', 'required',
'string', 'string',
'max:'.config('import.max_data_size'),
], ],
]; ];
} }

View File

@@ -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');

View File

@@ -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,
]; ];
} }
} }

View File

@@ -38,14 +38,10 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string|null $pending_email * @property string|null $pending_email
* @property Carbon|null $email_verified_at * @property Carbon|null $email_verified_at
* @property string|null $password * @property string|null $password
* @property string|null $remember_token
* @property string|null $two_factor_secret * @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @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
@@ -112,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',
]; ];
/** /**
@@ -122,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,
]; ];
/** /**
@@ -153,9 +147,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return $this->is_placeholder === false return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
&& in_array($this->email, config('auth.super_admins', []), true)
&& $this->hasVerifiedEmail();
} }
public function isMemberOfOrganization(Organization $organization): bool public function isMemberOfOrganization(Organization $organization): bool

View File

@@ -20,7 +20,6 @@ use App\Service\BillingContract;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService; use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use DateTimeInterface;
use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi; use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme; use Dedoc\Scramble\Support\Generator\SecurityScheme;
@@ -30,13 +29,8 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -104,27 +98,6 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class); $this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class); $this->app->bind(BillingContract::class);
// Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
// so mirror it through the signed query parameters of the storage route.
$privateDisk = config('filesystems.private');
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
$disk = Storage::disk($privateDisk);
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
});
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
$parameters = array_filter([
'path' => $path,
'disposition' => isset($options['ResponseContentDisposition'])
? Str::before($options['ResponseContentDisposition'], ';')
: null,
]);
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
});
}
// Routing // Routing
Route::model('member', Member::class); Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class); Route::model('invitation', OrganizationInvitation::class);

View File

@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Auth\ActiveUserProvider;
use App\Models\Passport\AuthCode; use App\Models\Passport\AuthCode;
use App\Models\Passport\Client; use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken; use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider class AuthServiceProvider extends ServiceProvider
@@ -29,13 +26,6 @@ class AuthServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Replaces the built-in eloquent user provider, so that no authentication flow can
// resolve a placeholder user. The driver name is kept, because Passport recognizes
// only providers that are configured with the driver "eloquent".
Auth::provider('eloquent', function (Application $app, array $config): ActiveUserProvider {
return new ActiveUserProvider($app->make('hash'), $config['model']);
});
// define scopes for passport tokens // define scopes for passport tokens
Passport::tokensCan([ Passport::tokensCan([
'create' => 'Create resources', 'create' => 'Create resources',

View File

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

View File

@@ -9,8 +9,11 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto; use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportService class ImportService
{ {
@@ -22,6 +25,8 @@ class ImportService
/** @var ImporterContract $importer */ /** @var ImporterContract $importer */
$importer = app(ImporterProvider::class)->getImporter($importerType); $importer = app(ImporterProvider::class)->getImporter($importerType);
$importer->init($organization); $importer->init($organization);
Storage::disk(config('filesystems.default'))
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1); $lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);

View File

@@ -16,6 +16,7 @@ use Illuminate\Support\Str;
use League\Csv\Reader; use League\Csv\Reader;
use Override; use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class SolidtimeImporter extends DefaultImporter class SolidtimeImporter extends DefaultImporter
{ {
@@ -33,10 +34,16 @@ class SolidtimeImporter extends DefaultImporter
$temporaryDirectoryZip = null; $temporaryDirectoryZip = null;
$temporaryDirectory = null; $temporaryDirectory = null;
try { try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make(); $temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data); file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make(); $temporaryDirectory = TemporaryDirectory::make();
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path()); $zip->extractTo($temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('meta.json'))) { if (! file_exists($temporaryDirectory->path('meta.json'))) {
throw new ImportException('File "meta.json" missing in ZIP'); throw new ImportException('File "meta.json" missing in ZIP');

View File

@@ -13,6 +13,7 @@ use Illuminate\Support\Str;
use Override; use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError; use ValueError;
use ZipArchive;
class TogglDataImporter extends DefaultImporter class TogglDataImporter extends DefaultImporter
{ {
@@ -25,10 +26,16 @@ class TogglDataImporter extends DefaultImporter
$temporaryDirectoryZip = null; $temporaryDirectoryZip = null;
$temporaryDirectory = null; $temporaryDirectory = null;
try { try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make(); $temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data); file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make(); $temporaryDirectory = TemporaryDirectory::make();
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path()); $zip->extractTo($temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('clients.json'))) { if (! file_exists($temporaryDirectory->path('clients.json'))) {
throw new ImportException('File "clients.json" missing in ZIP'); throw new ImportException('File "clients.json" missing in ZIP');
} }

View File

@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use ZipArchive;
/**
* Extracts uploaded ZIP archives with limits on file count, total uncompressed
* size and entry paths, so a small malicious archive can not fill the disk
* (decompression bomb) or write outside the target directory (zip slip).
*/
class ZipImportHelper
{
private const int CHUNK_SIZE = 1024 * 1024;
/**
* @throws ImportException
*/
public function extract(string $zipPath, string $targetPath): void
{
$zip = new ZipArchive;
$res = $zip->open($zipPath, ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
try {
$maxFiles = (int) config('import.zip_max_files');
$maxUncompressedSize = (int) config('import.zip_max_uncompressed_size');
if ($zip->numFiles > $maxFiles) {
throw new ImportException('ZIP contains too many files, maximum is '.$maxFiles);
}
// Check the sizes declared in the archive before writing anything to disk
$declaredSize = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
if ($stat === false) {
throw new ImportException('Invalid ZIP entry');
}
$this->validateEntryName($stat['name']);
$declaredSize += $stat['size'];
if ($declaredSize > $maxUncompressedSize) {
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
}
}
// The declared sizes can be forged, so the written bytes are counted as well
$writtenSize = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
if ($stat === false) {
throw new ImportException('Invalid ZIP entry');
}
$name = $stat['name'];
$entryPath = $targetPath.DIRECTORY_SEPARATOR.$name;
if (str_ends_with($name, '/')) {
$this->ensureDirectoryExists($entryPath);
continue;
}
$this->ensureDirectoryExists(dirname($entryPath));
$stream = $zip->getStreamIndex($index);
if ($stream === false) {
throw new ImportException('ZIP entry "'.$name.'" can not be read');
}
$target = fopen($entryPath, 'wb');
if ($target === false) {
fclose($stream);
throw new ImportException('ZIP entry "'.$name.'" can not be extracted');
}
try {
while (! feof($stream)) {
$chunk = fread($stream, self::CHUNK_SIZE);
if ($chunk === false) {
throw new ImportException('ZIP entry "'.$name.'" can not be read');
}
$writtenSize += strlen($chunk);
if ($writtenSize > $maxUncompressedSize) {
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
}
fwrite($target, $chunk);
}
} finally {
fclose($target);
fclose($stream);
}
}
} finally {
$zip->close();
}
}
/**
* @throws ImportException
*/
private function validateEntryName(string $name): void
{
if ($name === '' || str_contains($name, "\0") || str_contains($name, '\\') || str_starts_with($name, '/')) {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
if (preg_match('/^[a-zA-Z]:/', $name) === 1) {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
foreach (explode('/', rtrim($name, '/')) as $segment) {
if ($segment === '' || $segment === '..') {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
}
}
/**
* @throws ImportException
*/
private function ensureDirectoryExists(string $path): void
{
if (is_dir($path)) {
return;
}
if (! mkdir($path, 0700, true) && ! is_dir($path)) {
throw new ImportException('Directory "'.$path.'" can not be created');
}
}
}

View File

@@ -18,22 +18,6 @@ use Illuminate\Support\Facades\Mail;
class InvitationService 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 * @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/ */

View File

@@ -218,16 +218,7 @@ class MemberService
$placeholderUser = $user->replicate(); $placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true; $placeholderUser->is_placeholder = true;
// Reset authentication relevant properties on the placeholder user $placeholderUser->current_team_id = $member->organization_id;
$placeholderUser->password = null;
$placeholderUser->remember_token = null;
$placeholderUser->two_factor_secret = null;
$placeholderUser->two_factor_recovery_codes = null;
$placeholderUser->two_factor_confirmed_at = null;
$placeholderUser->email_verified_at = null;
$placeholderUser->pending_email = null;
$placeholderUser->current_team_id = null;
$placeholderUser->profile_photo_path = null;
$placeholderUser->save(); $placeholderUser->save();
$member->user()->associate($placeholderUser); $member->user()->associate($placeholderUser);

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Import payload limit
|--------------------------------------------------------------------------
|
| Maximum length of the base64 encoded "data" field of an import request in
| bytes. Requests with a larger payload are rejected with a validation error.
|
*/
'max_data_size' => (int) (env('IMPORT_MAX_DATA_SIZE') ?: 50 * 1024 * 1024),
/*
|--------------------------------------------------------------------------
| ZIP extraction limits
|--------------------------------------------------------------------------
|
| Limits applied to ZIP based importers before and during extraction to
| protect the instance against decompression bombs. The uncompressed size
| is the sum of all files in the archive in bytes.
|
*/
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),
];

View File

@@ -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');
});
}
};

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Placeholder users used to be created as a full copy of the user they were made from,
* which included the credentials and the account state of that user. A placeholder is a
* stand-in for a person in one organization, not an account, and the row shares the email
* address with the real account, so these values are removed from the placeholders that
* already exist. The organization a placeholder belongs to is recorded on its member row.
*/
public function up(): void
{
DB::table('users')
->where('is_placeholder', '=', true)
->where(function (Builder $builder): void {
$builder->whereNotNull('password')
->orWhereNotNull('remember_token')
->orWhereNotNull('two_factor_secret')
->orWhereNotNull('two_factor_recovery_codes')
->orWhereNotNull('two_factor_confirmed_at')
->orWhereNotNull('email_verified_at')
->orWhereNotNull('pending_email')
->orWhereNotNull('current_team_id')
->orWhereNotNull('profile_photo_path');
})
->update([
'password' => null,
'remember_token' => null,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
'email_verified_at' => null,
'pending_email' => null,
'current_team_id' => null,
'profile_photo_path' => null,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@@ -1,6 +1,7 @@
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.11 ARG FRANKENPHP_VERSION=1.8
ARG COMPOSER_VERSION=2.8 ARG COMPOSER_VERSION=2.8
ARG BUN_VERSION="latest"
ARG APP_ENV ARG APP_ENV
ARG DOCKER_FILES_BASE_PATH="docker/prod/" ARG DOCKER_FILES_BASE_PATH="docker/prod/"
@@ -15,13 +16,13 @@ RUN CGO_ENABLED=1 \
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \ XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
CGO_CFLAGS=$(php-config --includes) \ CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build \ xcaddy build v2.10.0 \
--output /usr/local/bin/frankenphp \ --output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \ --with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \ --with github.com/dunglas/frankenphp/caddy=./caddy/ \
--with github.com/dunglas/caddy-cbrotli --with github.com/dunglas/caddy-cbrotli
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
@@ -31,28 +32,26 @@ LABEL org.opencontainers.image.description="solidtime is a modern open source ti
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime" LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
LABEL org.opencontainers.image.licenses="AGPL" LABEL org.opencontainers.image.licenses="AGPL"
ARG USER_ID=1000 ARG WWWUSER=1000
ARG GROUP_ID=1000 ARG WWWGROUP=1000
ARG TZ=UTC ARG TZ=UTC
ARG APP_DIR=/var/www/html
ARG APP_ENV
ARG APP_HOST
ARG DOCKER_FILES_BASE_PATH ARG DOCKER_FILES_BASE_PATH
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \ TERM=xterm-color \
OCTANE_SERVER=frankenphp \ OCTANE_SERVER=frankenphp \
TZ=${TZ} \ TZ=${TZ} \
LANG=C.UTF-8 \ USER=octane \
USER=laravel \ ROOT=${APP_DIR} \
ROOT=/var/www/html \ APP_ENV=${APP_ENV} \
APP_ENV=production \
COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_FUND=0 \ COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=48 \ COMPOSER_MAX_PARALLEL_HTTP=24 \
WITH_HORIZON=false \ XDG_CONFIG_HOME=${APP_DIR}/.config \
WITH_SCHEDULER=false \ XDG_DATA_HOME=${APP_DIR}/.data \
WITH_REVERB=false \ SERVER_NAME=${APP_HOST}
WITH_SSR=false
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
WORKDIR ${ROOT} WORKDIR ${ROOT}
@@ -61,9 +60,6 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone && echo ${TZ} > /etc/timezone
RUN echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
RUN apt-get update; \ RUN apt-get update; \
apt-get upgrade -yqq; \ apt-get upgrade -yqq; \
apt-get install -yqq --no-install-recommends --show-progress \ apt-get install -yqq --no-install-recommends --show-progress \
@@ -72,36 +68,40 @@ RUN apt-get update; \
wget \ wget \
vim \ vim \
git \ git \
unzip \
ncdu \ ncdu \
procps \ procps \
unzip \
ca-certificates \ ca-certificates \
supervisor \ supervisor \
libsodium-dev \ libsodium-dev \
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \ libbrotli-dev \
# Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \ && install-php-extensions \
apcu \
bz2 \ bz2 \
pcntl \ pcntl \
mbstring \ mbstring \
bcmath \ bcmath \
sockets \ sockets \
pgsql \
pdo_pgsql \ pdo_pgsql \
opcache \ opcache \
exif \ exif \
pdo_mysql \ pdo_mysql \
zip \ zip \
uv \ uv \
vips \
intl \ intl \
gd \ gd \
redis \ redis \
rdkafka \ rdkafka \
ffi \ memcached \
igbinary \
ldap \ ldap \
&& apt-get -y autoremove \ && apt-get -y autoremove \
&& apt-get clean \ && apt-get clean \
&& docker-php-source delete \ && docker-php-source delete \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm /var/log/lastlog /var/log/faillog
RUN arch="$(uname -m)" \ RUN arch="$(uname -m)" \
&& case "$arch" in \ && case "$arch" in \
@@ -111,64 +111,106 @@ RUN arch="$(uname -m)" \
x86) _cronic_fname='supercronic-linux-386' ;; \ x86) _cronic_fname='supercronic-linux-386' ;; \
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \ *) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
esac \ esac \
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \ && wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
-O /usr/bin/supercronic \ -O /usr/bin/supercronic \
&& chmod +x /usr/bin/supercronic \ && chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \ && mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel && echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
RUN userdel --remove --force www-data \ RUN userdel --remove --force www-data \
&& groupadd --force -g ${GROUP_ID} ${USER} \ && groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER} && useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
&& setcap -r /usr/local/bin/frankenphp
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
&& chmod -R a+rw ${ROOT} /var/{log,run}
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer USER ${USER}
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-php.ini
#COPY --link composer.* ./
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
###########################################
#FROM base AS common
#
#USER ${USER}
#
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#
#RUN composer install \ #RUN composer install \
# --no-dev \ # --no-dev \
# --no-interaction \ # --no-interaction \
# --no-autoloader \ # --no-autoloader \
# --no-ansi \ # --no-ansi \
# --no-scripts \ # --no-scripts \
# --no-progress \
# --audit # --audit
###########################################
# Build frontend assets with Bun
###########################################
#FROM oven/bun:${BUN_VERSION} AS build
#
#ARG APP_ENV
#
#ENV ROOT=/var/www/html \
# APP_ENV=${APP_ENV} \
# NODE_ENV=${APP_ENV:-production}
#
#WORKDIR ${ROOT}
#
#COPY --link package.json bun.lock* ./ #COPY --link package.json bun.lock* ./
#
#RUN bun install --frozen-lockfile #RUN bun install --frozen-lockfile
#
#COPY --link . .
#COPY --link --from=common ${ROOT}/vendor vendor
#
#RUN bun run build
COPY --link . . ###########################################
#FROM common AS runner
USER ${USER}
ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
RUN test -z "$(find . -name .git -print -quit)"
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \ RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \ storage/framework/{sessions,views,cache,testing} \
storage/logs \ storage/logs \
bootstrap/cache \ bootstrap/cache && chmod -R a+rw storage
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
RUN composer dump-autoload \ #RUN composer install \
--optimize \ # --classmap-authoritative \
--apcu \ # --no-interaction \
--no-dev # --no-ansi \
# --no-dev \
# && composer clear-cache
#RUN bun run build RUN cat .env
#RUN php artisan env
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
USER ${USER}
EXPOSE 8000 EXPOSE 8000
#EXPOSE 2019
#EXPOSE 8080
ENTRYPOINT ["start-container"] ENTRYPOINT ["start-container"]
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1 #HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1

View File

@@ -22,13 +22,6 @@ elif [ "${container_mode}" = "reverb" ]; then
echo "Healthcheck failed." echo "Healthcheck failed."
exit 1 exit 1
fi fi
elif [ "${container_mode}" = "ssr" ]; then
if [ "$(supervisorctl status inertia-ssr-server:inertia-ssr-server_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "worker" ]; then elif [ "${container_mode}" = "worker" ]; then
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0 exit 0

View File

@@ -3,14 +3,6 @@
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT} admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
log {
level {$CADDY_SERVER_LOG_LEVEL:WARN}
}
auto_https off
skip_install_trust
frankenphp { frankenphp {
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT} worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
} }
@@ -28,7 +20,7 @@
{$CADDY_SERVER_SERVER_NAME} { {$CADDY_SERVER_SERVER_NAME} {
log { log {
level {$CADDY_SERVER_LOG_LEVEL:WARN} level WARN
format filter { format filter {
wrap {$CADDY_SERVER_LOGGER} wrap {$CADDY_SERVER_LOGGER}
@@ -68,6 +60,7 @@
error @rejected 401 error @rejected 401
php_server { php_server {
index frankenphp-worker.php
try_files {path} frankenphp-worker.php try_files {path} frankenphp-worker.php
resolve_root_symlink resolve_root_symlink
} }

View File

@@ -1,18 +1,65 @@
[program:octane] [program:octane]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-host=0.0.0.0 --admin-port=2019 --log-level=WARN --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
user = %(ENV_USER)s user = %(ENV_USER)s
priority = 1 priority = 1
autostart = true autostart = true
autorestart = true autorestart = true
stopwaitsecs = 30
stopasgroup = true
killasgroup = true
environment = LARAVEL_OCTANE = "1" environment = LARAVEL_OCTANE = "1"
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0 stderr_logfile_maxbytes = 0
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
priority = 3
autostart = %(ENV_WITH_HORIZON)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes = 200MB
stopwaitsecs = 3600
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = false
startsecs = 0
startretries = 1
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 2
autostart = %(ENV_WITH_REVERB)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stderr_logfile_maxbytes = 200MB
minfds = 10000
[include] [include]
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf files = /etc/supervisord.conf

View File

@@ -2,9 +2,8 @@
post_max_size = 100M post_max_size = 100M
upload_max_filesize = 100M upload_max_filesize = 100M
expose_php = 0 expose_php = 0
realpath_cache_size = 32M realpath_cache_size = 16M
realpath_cache_ttl = 720 realpath_cache_ttl = 360
memory_limit = 256M
max_input_time = 5 max_input_time = 5
register_argc_argv = 0 register_argc_argv = 0
date.timezone = ${TZ:-UTC} date.timezone = ${TZ:-UTC}
@@ -12,24 +11,18 @@ date.timezone = ${TZ:-UTC}
[Opcache] [Opcache]
opcache.enable = 1 opcache.enable = 1
opcache.enable_cli = 1 opcache.enable_cli = 1
opcache.memory_consumption = 256 opcache.memory_consumption = 256M
opcache.use_cwd = 0 opcache.use_cwd = 0
opcache.save_comments = 1
opcache.max_file_size = 0 opcache.max_file_size = 0
opcache.max_accelerated_files = 32531 opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0 opcache.validate_timestamps = 0
opcache.file_update_protection = 0 opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16 opcache.interned_strings_buffer = 16
opcache.enable_file_override = 1
opcache.file_cache_consistency_checks = 0
opcache.file_cache = /tmp/opcache-file-cache
[JIT] [JIT]
opcache.jit_buffer_size = 128M opcache.jit_buffer_size = 128M
opcache.jit = tracing opcache.jit = function
opcache.jit_hot_loop = 16 opcache.jit_prof_threshold = 0.001
opcache.jit_hot_func = 32
opcache.jit_hot_return = 4
opcache.jit_max_root_traces = 2048 opcache.jit_max_root_traces = 2048
opcache.jit_max_side_traces = 256 opcache.jit_max_side_traces = 256

View File

@@ -1,6 +1,8 @@
[supervisord] [supervisord]
nodaemon = true nodaemon = true
user = %(ENV_USER)s user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid
[supervisorctl] [supervisorctl]

View File

@@ -4,8 +4,6 @@ command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -1,15 +0,0 @@
[program:inertia-ssr-server]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
user = %(ENV_USER)s
autostart = true
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
[include]
files = /etc/supervisord.conf

View File

@@ -4,8 +4,6 @@ command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -4,8 +4,6 @@ command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -1,69 +0,0 @@
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
priority = 4
autostart = %(ENV_WITH_HORIZON)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes = 200MB
stopwaitsecs = 3600
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = false
startsecs = 0
startretries = 1
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 3
autostart = %(ENV_WITH_REVERB)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stderr_logfile_maxbytes = 200MB
minfds = 10000
[program:inertia-ssr-server]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
user = %(ENV_USER)s
priority = 2
autostart = %(ENV_WITH_SSR)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
stderr_logfile_maxbytes = 200MB

View File

@@ -4,8 +4,6 @@ command = %(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -8,7 +8,7 @@ import {
createProjectViaApi, createProjectViaApi,
createPublicProjectViaApi, createPublicProjectViaApi,
} from './utils/api'; } from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table'; import { getTableRowNames } from './utils/table';
async function goToClientsOverview(page: Page) { async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
@@ -210,12 +210,18 @@ test('test that client context menu delete deletes the client', async ({ page, c
// Sorting Tests // Sorting Tests
// ============================================= // =============================================
async function clearClientTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('client-table-state');
});
}
test('test that sorting clients by name and status works', async ({ page, ctx }) => { test('test that sorting clients by name and status works', async ({ page, ctx }) => {
await createClientViaApi(ctx, { name: 'AAA SortClient' }); await createClientViaApi(ctx, { name: 'AAA SortClient' });
await createClientViaApi(ctx, { name: 'ZZZ SortClient' }); await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
await goToClientsOverview(page); await goToClientsOverview(page);
await clearTableState(page, 'client-table-state'); await clearClientTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('client_table'); const table = page.getByTestId('client_table');
@@ -247,7 +253,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id }); await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
await goToClientsOverview(page); await goToClientsOverview(page);
await clearTableState(page, 'client-table-state'); await clearClientTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('client_table'); const table = page.getByTestId('client_table');
@@ -268,7 +274,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
test('test that client sort state persists after page reload', async ({ page }) => { test('test that client sort state persists after page reload', async ({ page }) => {
await goToClientsOverview(page); await goToClientsOverview(page);
await clearTableState(page, 'client-table-state'); await clearClientTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('client_table'); const table = page.getByTestId('client_table');
@@ -391,7 +397,7 @@ test.describe('Clients Pagination', () => {
); );
await goToClientsOverview(page); await goToClientsOverview(page);
await clearTableState(page, 'client-table-state'); await clearClientTableState(page);
await page.reload(); await page.reload();
// Default sort is name asc; first 15 clients (00–14) on page 1. // Default sort is name asc; first 15 clients (00–14) on page 1.
@@ -444,7 +450,7 @@ test.describe('Clients Pagination', () => {
); );
await goToClientsOverview(page); await goToClientsOverview(page);
await clearTableState(page, 'client-table-state'); await clearClientTableState(page);
await page.reload(); await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible(); await expect(page.getByTestId('client_table')).toBeVisible();
@@ -464,7 +470,7 @@ test.describe('Clients Pagination', () => {
); );
await goToClientsOverview(page); await goToClientsOverview(page);
await clearTableState(page, 'client-table-state'); await clearClientTableState(page);
await page.reload(); await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 }); await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });

View File

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

@@ -11,7 +11,7 @@ import {
updateMemberBillableRateViaApi, updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table'; import { getTableRowNames } from './utils/table';
// Tests that invite + accept members need more time // Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 }); test.describe.configure({ timeout: 45000 });
@@ -779,18 +779,20 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
// Sorting Tests // Sorting Tests
// ============================================= // =============================================
// Helper to clear localStorage before tests that check sorting
async function clearMemberTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('member-table-state');
});
}
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => { test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
// Create two placeholder members with names that sort predictably around "John Doe". // Create two placeholder members with names that sort predictably around "John Doe"
// Seeded alphabetically a second apart: created_at only has second precision and
// same-second rows fall back to a random UUID order. The spacing is what makes the
// API order (created_at desc: ZZZ, AAA, John) deterministic, so the tie-break
// assertions below are testing the tie-break rather than a coin flip.
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst'); await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await page.waitForTimeout(1100);
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast'); await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page); await goToMembersPage(page);
await clearTableState(page, 'member-table-state'); await clearMemberTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('member_table'); const table = page.getByTestId('member_table');
@@ -812,24 +814,20 @@ test('test that sorting members by name, role, and status works', async ({ page,
const ownerIdx = names.indexOf('John Doe'); const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst'); const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx); expect(ownerIdx).toBeLessThan(placeholderIdx);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await roleHeader.click(); // desc: Placeholder first await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table); names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Status sorting -- // -- Status sorting --
const statusHeader = table.getByText('Status').first(); const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1) await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table); names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst')); expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await statusHeader.click(); // desc: Inactive first await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table); names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Email: just verify sort indicator appears -- // -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first(); const emailHeader = table.getByText('Email').first();
@@ -839,7 +837,7 @@ test('test that sorting members by name, role, and status works', async ({ page,
test('test that member sort state persists after page reload', async ({ page }) => { test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page); await goToMembersPage(page);
await clearTableState(page, 'member-table-state'); await clearMemberTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('member_table'); const table = page.getByTestId('member_table');
@@ -877,7 +875,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000); await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page); await goToMembersPage(page);
await clearTableState(page, 'member-table-state'); await clearMemberTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('member_table'); const table = page.getByTestId('member_table');

View File

@@ -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,
}) => { }) => {

View File

@@ -13,12 +13,18 @@ import {
archiveProjectViaApi, archiveProjectViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
import { clearTableState, getSeededRowOrder } from './utils/table';
async function goToProjectsOverview(page: Page) { async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
} }
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal // Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => { test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -78,7 +84,7 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await createProjectViaApi(ctx, { name: newProjectName }); await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -474,7 +480,7 @@ test('test that sorting projects by all columns works', async ({ page, ctx }) =>
}); });
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible(); await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByText('AAA Project')).toBeVisible(); await expect(page.getByText('AAA Project')).toBeVisible();
@@ -603,7 +609,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: newProjectName }); await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -634,7 +640,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
test('test that filter state persists after page reload', async ({ page }) => { test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
// Apply Active status filter // Apply Active status filter
@@ -650,96 +656,9 @@ test('test that filter state persists after page reload', async ({ page }) => {
await expect(page.getByTestId('status-filter-badge')).toBeVisible(); await expect(page.getByTestId('status-filter-badge')).toBeVisible();
}); });
test('test that projects without a client or estimate are ordered by name at the bottom', async ({
page,
ctx,
}) => {
// Seeded a second apart: created_at only has second precision and same-second rows
// fall back to a random UUID order. The spacing makes the API order of the clientless
// rows (created_at desc: ZZZ, AAA) deterministic and different from the alphabetical
// order the name tie-break should produce.
await createProjectViaApi(ctx, { name: 'AAA Tiebreak Project' });
await page.waitForTimeout(1100);
await createProjectViaApi(ctx, { name: 'ZZZ Tiebreak Project' });
const clientAardvark = await createClientViaApi(ctx, { name: 'Aardvark Co' });
const clientZulu = await createClientViaApi(ctx, { name: 'Zulu Co' });
const projectM = await createProjectViaApi(ctx, {
name: 'MMM Tiebreak Project',
client_id: clientAardvark.id,
estimated_time: 36000, // 10h, 1h tracked below = 10%
});
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: projectM.id });
const projectN = await createProjectViaApi(ctx, {
name: 'NNN Tiebreak Project',
client_id: clientZulu.id,
estimated_time: 14400, // 4h, 2h tracked below = 50%
});
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: projectN.id });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
const table = page.getByTestId('project_table');
await expect(table).toBeVisible();
const seeded = [
'AAA Tiebreak Project',
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'ZZZ Tiebreak Project',
];
const getOrder = () => getSeededRowOrder(table, seeded);
// -- Client: empty rows last in both directions, alphabetical among themselves --
const clientHeader = table.locator('.select-none', { hasText: 'Client' }).first();
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
// -- Progress: same, and the first click sorts highest first --
const progressHeader = table.locator('.select-none', { hasText: 'Progress' }).first();
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
});
test('test that sort state persists after page reload', async ({ page }) => { test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
// Click on Name header twice to sort descending // Click on Name header twice to sort descending
@@ -1195,7 +1114,7 @@ test.describe('Projects Pagination', () => {
); );
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
// Default sort is name asc; first 15 projects (00–14) should be on page 1. // Default sort is name asc; first 15 projects (00–14) should be on page 1.
@@ -1249,7 +1168,7 @@ test.describe('Projects Pagination', () => {
); );
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible(); await expect(page.getByTestId('project_table')).toBeVisible();
@@ -1266,7 +1185,7 @@ test.describe('Projects Pagination', () => {
); );
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 }); await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
@@ -1288,36 +1207,3 @@ test.describe('Projects Pagination', () => {
await expect(page.getByText(prefix + '00')).not.toBeVisible(); await expect(page.getByText(prefix + '00')).not.toBeVisible();
}); });
}); });
test('test that searching projects by name works', async ({ page, ctx }) => {
const suffix = Math.floor(1 + Math.random() * 10000);
const matchingProjectName = 'Searchable Project ' + suffix;
const otherProjectName = 'Unrelated Work ' + suffix;
await createProjectViaApi(ctx, { name: matchingProjectName });
await createProjectViaApi(ctx, { name: otherProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(otherProjectName)).toBeVisible();
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
// Searching is case insensitive and matches part of the name
await searchInput.fill('SEARCHABLE');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
// A term that matches nothing empties the table
await searchInput.fill('no project has this name');
await expect(page.getByText(matchingProjectName)).not.toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
await expect(page.getByText('No matching projects')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
// Clearing the search restores both projects
await searchInput.fill('');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).toBeVisible();
});

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { createTagViaApi } from './utils/api'; import { createTagViaApi } from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table'; import { getTableRowNames } from './utils/table';
async function goToTagsOverview(page: Page) { async function goToTagsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/tags'); await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
@@ -147,12 +147,18 @@ test('test that tag context menu delete deletes the tag', async ({ page, ctx })
// Sorting Tests // Sorting Tests
// ============================================= // =============================================
async function clearTagTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('tag-table-state');
});
}
test('test that sorting tags by name works', async ({ page, ctx }) => { test('test that sorting tags by name works', async ({ page, ctx }) => {
await createTagViaApi(ctx, { name: 'AAA SortTag' }); await createTagViaApi(ctx, { name: 'AAA SortTag' });
await createTagViaApi(ctx, { name: 'ZZZ SortTag' }); await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
await goToTagsOverview(page); await goToTagsOverview(page);
await clearTableState(page, 'tag-table-state'); await clearTagTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('tag_table'); const table = page.getByTestId('tag_table');
@@ -170,7 +176,7 @@ test('test that sorting tags by name works', async ({ page, ctx }) => {
test('test that tag sort state persists after page reload', async ({ page }) => { test('test that tag sort state persists after page reload', async ({ page }) => {
await goToTagsOverview(page); await goToTagsOverview(page);
await clearTableState(page, 'tag-table-state'); await clearTagTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('tag_table'); const table = page.getByTestId('tag_table');

View File

@@ -7,56 +7,13 @@ import {
createPublicProjectViaApi, createPublicProjectViaApi,
createTaskViaApi, createTaskViaApi,
createClientViaApi, createClientViaApi,
createTimeEntryViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
type TestContext,
} from './utils/api'; } from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
async function goToProjectsOverview(page: Page) { async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
} }
async function createSortableTasks(ctx: TestContext) {
const project = await createProjectViaApi(ctx, { name: 'Task Sorting Project' });
const taskA = await createTaskViaApi(ctx, {
name: 'AAA Sorting Task',
project_id: project.id,
estimated_time: 36000,
});
const taskB = await createTaskViaApi(ctx, {
name: 'BBB Sorting Task',
project_id: project.id,
estimated_time: 14400,
});
const taskC = await createTaskViaApi(ctx, {
name: 'CCC Sorting Task',
project_id: project.id,
});
expect(taskA.estimated_time).toBe(36000);
expect(taskB.estimated_time).toBe(14400);
expect(taskC.estimated_time).toBeNull();
await createTimeEntryViaApi(ctx, {
duration: '1h',
projectId: project.id,
taskId: taskA.id,
});
await createTimeEntryViaApi(ctx, {
duration: '2h',
projectId: project.id,
taskId: taskB.id,
});
await createTimeEntryViaApi(ctx, {
duration: '3h',
projectId: project.id,
taskId: taskC.id,
});
return { project, taskA, taskB, taskC };
}
test('test that creating and deleting a new task in a new project works', async ({ page }) => { test('test that creating and deleting a new task in a new project works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
@@ -344,59 +301,6 @@ test('test that creating a new project from the task create modal project dropdo
await expect(page.getByTestId('task_table')).toContainText(newTaskName); await expect(page.getByTestId('task_table')).toContainText(newTaskName);
}); });
// =============================================
// Sorting Tests
// =============================================
test('test that sorting tasks by name, total time and progress works', async ({ page, ctx }) => {
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
await goToProjectsOverview(page);
await clearTableState(page, 'task-table-state');
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
const table = page.getByTestId('task_table');
await expect(table).toBeVisible();
// This project contains only the seeded tasks, so assert the complete order.
const expectOrder = async (expected: string[]) => {
await expect.poll(() => getTableRowNames(table)).toEqual(expected);
};
const clickHeader = async (headerText: string) => {
await table.getByText(headerText).first().click();
};
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Task Name');
await expectOrder([taskC.name, taskB.name, taskA.name]);
await clickHeader('Task Name');
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Total Time');
await expectOrder([taskC.name, taskB.name, taskA.name]);
await clickHeader('Total Time');
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Progress');
await expectOrder([taskB.name, taskA.name, taskC.name]);
await clickHeader('Progress');
await expectOrder([taskA.name, taskB.name, taskC.name]);
});
test('test that task sort state persists after page reload', async ({ page, ctx }) => {
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
await goToProjectsOverview(page);
await clearTableState(page, 'task-table-state');
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
const table = page.getByTestId('task_table');
await expect(table).toBeVisible();
await table.getByText('Progress').first().click();
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
await page.reload();
// Verify the persisted row order, not just the sort indicator.
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
});
// ============================================= // =============================================
// Employee Permission Tests // Employee Permission Tests
// ============================================= // =============================================

View File

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

@@ -357,7 +357,7 @@ export async function createProjectWithClientViaApi(
export async function createTaskViaApi( export async function createTaskViaApi(
ctx: TestContext, ctx: TestContext,
data: { name: string; project_id: string; estimated_time?: number } data: { name: string; project_id: string }
) { ) {
const response = await ctx.request.post( const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`, `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,
@@ -365,20 +365,12 @@ export async function createTaskViaApi(
data: { data: {
name: data.name, name: data.name,
project_id: data.project_id, project_id: data.project_id,
...(data.estimated_time !== undefined
? { estimated_time: data.estimated_time }
: {}),
}, },
} }
); );
expect(response.status()).toBe(201); expect(response.status()).toBe(201);
const body = await response.json(); const body = await response.json();
return body.data as { return body.data as { id: string; name: string; project_id: string };
id: string;
name: string;
project_id: string;
estimated_time: number | null;
};
} }
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) { export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {

View File

@@ -1,4 +1,4 @@
import type { Locator, Page } from '@playwright/test'; import type { Locator } from '@playwright/test';
/** /**
* Extract the first cell's text content from each row in a table. * Extract the first cell's text content from each row in a table.
@@ -14,22 +14,3 @@ export async function getTableRowNames(table: Locator): Promise<string[]> {
} }
return names; return names;
} }
/**
* The visual order of the given seeded names within the table, ignoring any other rows.
*/
export async function getSeededRowOrder(table: Locator, seeded: string[]): Promise<string[]> {
const rowNames = await getTableRowNames(table);
return rowNames
.map((rowName) => seeded.find((name) => rowName.includes(name)))
.filter((name): name is string => Boolean(name));
}
/**
* Drop a table's persisted sort/filter state so a test starts from the defaults.
*/
export async function clearTableState(page: Page, key: string) {
await page.evaluate((storageKey) => {
localStorage.removeItem(storageKey);
}, key);
}

View File

@@ -1,7 +1,7 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "main" "ref": "v0.0.5"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
@@ -9,6 +9,6 @@
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.6" "ref": "v0.0.2"
} }
} }

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

View File

@@ -43,7 +43,5 @@
<env name="NEWSLETTER_URL" value="null"/> <env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
<env name="PADDLE_SANDBOX" value="true"/>
</php> </php>
</phpunit> </phpunit>

View File

@@ -7,17 +7,18 @@ import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue'; import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue'; import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import Pagination from '@/packages/ui/src/Pagination.vue'; import Pagination from '@/Components/Common/Pagination.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { import {
useSortableTable, useVueTable,
type SortableColumnDef, getCoreRowModel,
type SortDirection, getSortedRowModel,
} from '@/utils/useSortableTable'; type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name' | 'projects_count' | 'status'; export type SortColumn = 'name' | 'projects_count' | 'status';
export type { SortDirection } from '@/utils/useSortableTable'; export type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
clients: Client[]; clients: Client[];
@@ -43,7 +44,17 @@ const projectCountMap = computed(() => {
return map; return map;
}); });
const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [ // Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
const columns = computed(() => [
{ {
id: 'name', id: 'name',
accessorFn: (row: Client) => row.name.toLowerCase(), accessorFn: (row: Client) => row.name.toLowerCase(),
@@ -59,21 +70,40 @@ const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [
}, },
]); ]);
const { const descFirstColumns = new Set<SortColumn>(
sortedRows: sortedClients, columns.value
descFirstColumns, .filter((c) => 'sortDescFirst' in c && c.sortDescFirst)
nextDirection, .map((c) => c.id as SortColumn)
} = useSortableTable({ );
data: () => props.clients,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column)); if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
} }
}
const table = useVueTable({
get data() {
return props.clients;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedClients = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
// Client-side pagination: the full list is in memory, only one page is mounted at a time. // Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15; const PAGE_SIZE = 15;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -9,29 +9,57 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>; descFirstColumns: ReadonlySet<SortColumn>;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn]; sort: [column: SortColumn];
}>(); }>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
column="name" @click="handleSort('name')">
v-bind="props"
@sort="$emit('sort', $event)">
Name Name
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<SortableTableHeaderCell <ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
column="projects_count" <span v-else class="w-4 h-4"></span>
v-bind="props" </div>
@sort="$emit('sort', $event)"> <div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('projects_count')">
Projects Projects
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)"> <ChevronUpIcon v-else-if="isChevronUp('projects_count')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status Status
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -3,14 +3,16 @@ import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vu
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue'; import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersQuery } from '@/utils/useMembersQuery'; import { useMembersQuery } from '@/utils/useMembersQuery';
import type { Member } from '@/packages/api/src'; import type { Member } from '@/packages/api/src';
import { computed } from 'vue';
import { import {
useSortableTable, useVueTable,
type SortableColumnDef, getCoreRowModel,
type SortDirection, getSortedRowModel,
} from '@/utils/useSortableTable'; type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status'; export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
export type { SortDirection } from '@/utils/useSortableTable'; export type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
sortColumn: SortColumn; sortColumn: SortColumn;
@@ -31,7 +33,14 @@ const roleOrder: Record<string, number> = {
placeholder: 4, placeholder: 4,
}; };
const columns: SortableColumnDef<Member, SortColumn>[] = [ const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
{ {
id: 'name', id: 'name',
accessorFn: (row: Member) => row.name.toLowerCase(), accessorFn: (row: Member) => row.name.toLowerCase(),
@@ -47,6 +56,7 @@ const columns: SortableColumnDef<Member, SortColumn>[] = [
{ {
id: 'billable_rate', id: 'billable_rate',
sortDescFirst: true, sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Member) => { accessorFn: (row: Member) => {
if (row.billable_rate === null) return undefined; if (row.billable_rate === null) return undefined;
return row.billable_rate; return row.billable_rate;
@@ -58,21 +68,36 @@ const columns: SortableColumnDef<Member, SortColumn>[] = [
}, },
]; ];
const { const descFirstColumns = new Set<SortColumn>(
sortedRows: sortedMembers, columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
descFirstColumns, );
nextDirection,
} = useSortableTable({
data: () => members.value,
columns: () => columns,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column)); if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
} }
}
const table = useVueTable({
get data() {
return members.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedMembers = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script> </script>
<template> <template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -9,35 +9,73 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>; descFirstColumns: ReadonlySet<SortColumn>;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn]; sort: [column: SortColumn];
}>(); }>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
column="name" @click="handleSort('name')">
v-bind="props"
@sort="$emit('sort', $event)">
Name Name
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<SortableTableHeaderCell column="email" v-bind="props" @sort="$emit('sort', $event)"> <ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('email')">
Email Email
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
<SortableTableHeaderCell column="role" v-bind="props" @sort="$emit('sort', $event)"> <ChevronUpIcon v-else-if="isChevronUp('email')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('role')">
Role Role
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
<SortableTableHeaderCell <ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
column="billable_rate" <span v-else class="w-4 h-4"></span>
v-bind="props" </div>
@sort="$emit('sort', $event)"> <div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
Billable Rate Billable Rate
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)"> <ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status Status
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -16,8 +16,8 @@ import {
ChevronRightIcon, ChevronRightIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { buttonVariants } from './Buttons/index'; import { buttonVariants } from '@/packages/ui/src';
import { cn } from './utils/cn'; import { cn } from '@/lib/utils';
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
const page = defineModel<number>('page', { default: 1 }); const page = defineModel<number>('page', { default: 1 });

View File

@@ -6,12 +6,17 @@ import { computed, ref, watch } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/packages/ui/src/Pagination.vue'; import Pagination from '@/Components/Common/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn = export type SortColumn =
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility'; | 'name'
export type { SortDirection } from '@/utils/useSortableTable'; | 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
@@ -22,24 +27,20 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { import {
useSortableTable, useVueTable,
type SortableColumnDef, getCoreRowModel,
type SortDirection, getSortedRowModel,
} from '@/utils/useSortableTable'; type SortingState,
} from '@tanstack/vue-table';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const props = withDefaults( const props = defineProps<{
defineProps<{
projects: Project[]; projects: Project[];
showBillableRate: boolean; showBillableRate: boolean;
sortColumn: SortColumn; sortColumn: SortColumn;
sortDirection: SortDirection; sortDirection: SortDirection;
isFiltered?: boolean; }>();
isLoading?: boolean;
}>(),
{ isFiltered: false, isLoading: false }
);
const emit = defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection]; sort: [column: SortColumn, direction: SortDirection];
@@ -56,16 +57,28 @@ const clientNameMap = computed(() => {
return map; return map;
}); });
// Convert sort props to TanStack Table format.
// Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
// Define column accessors for sorting. // Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first, // Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down). // while text columns default to ascending (A-Z) on first click (chevron down).
const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [ const columns = computed(() => [
{ {
id: 'name', id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(), accessorFn: (row: Project) => row.name.toLowerCase(),
}, },
{ {
id: 'client_name', id: 'client_name',
sortUndefined: 'last' as const,
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
if (!row.client_id) return undefined; if (!row.client_id) return undefined;
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase(); return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
@@ -74,11 +87,12 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
{ {
id: 'spent_time', id: 'spent_time',
sortDescFirst: true, sortDescFirst: true,
accessorFn: (row: Project) => row.spent_time, accessorFn: (row: Project) => row.spent_time ?? 0,
}, },
{ {
id: 'progress', id: 'progress',
sortDescFirst: true, sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
if (!row.estimated_time) return undefined; if (!row.estimated_time) return undefined;
return (row.spent_time / row.estimated_time) * 100; return (row.spent_time / row.estimated_time) * 100;
@@ -87,7 +101,7 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
{ {
id: 'billable_rate', id: 'billable_rate',
sortDescFirst: true, sortDescFirst: true,
accessorFn: (row: Project) => row.billable_rate, accessorFn: (row: Project) => row.billable_rate ?? 0,
}, },
{ {
id: 'status', id: 'status',
@@ -99,21 +113,39 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
}, },
]); ]);
const { // Columns with sortDescFirst get desc as default direction on first click.
sortedRows: sortedProjects, const descFirstColumns = new Set<SortColumn>(
descFirstColumns, columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
nextDirection, );
} = useSortableTable({
data: () => props.projects,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column)); if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
} }
}
const table = useVueTable({
get data() {
return props.projects;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
// Client-side pagination: the full list is in memory, only one page is mounted at a time. // Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
@@ -128,28 +160,6 @@ const paginatedProjects = computed(() => {
return sortedProjects.value.slice(start, start + PAGE_SIZE); return sortedProjects.value.slice(start, start + PAGE_SIZE);
}); });
const emptyState = computed(() => {
if (props.isFiltered) {
return {
title: 'No matching projects',
description: 'Try a different search term or adjust your filters.',
showCreateButton: false,
};
}
if (!canCreateProjects()) {
return {
title: 'You are not a member of any projects',
description: 'Ask your manager to add you to a project as a team member.',
showCreateButton: false,
};
}
return {
title: 'No projects found',
description: 'Create your first project now!',
showCreateButton: true,
};
});
const showCreateProjectModal = ref(false); const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> { async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -183,21 +193,24 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection" :sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns" :desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading> @sort="handleSort"></ProjectTableHeading>
<div <div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
v-if="props.isLoading"
class="col-span-full flex justify-center items-center py-24">
<LoadingSpinner></LoadingSpinner>
</div>
<div
v-else-if="sortedProjects.length === 0"
class="col-span-full py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3> <h3 class="text-text-primary font-semibold">
{{
canCreateProjects()
? 'No projects found'
: 'You are not a member of any projects'
}}
</h3>
<p class="pb-5 max-w-md mx-auto text-sm pt-1"> <p class="pb-5 max-w-md mx-auto text-sm pt-1">
{{ emptyState.description }} {{
canCreateProjects()
? 'Create your first project now!'
: 'Ask your manager to add you to a project as a team member.'
}}
</p> </p>
<SecondaryButton <SecondaryButton
v-if="emptyState.showCreateButton" v-if="canCreateProjects()"
:icon="PlusIcon" :icon="PlusIcon"
@click="showCreateProjectModal = true" @click="showCreateProjectModal = true"
>Create your First Project >Create your First Project

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -15,51 +14,90 @@ const emit = defineEmits<{
sort: [column: SortColumn]; sort: [column: SortColumn];
}>(); }>();
// Bound once per cell instead of repeating the three sort props on every column.
const sortState = computed(() => ({
sortColumn: props.sortColumn,
sortDirection: props.sortDirection,
descFirstColumns: props.descFirstColumns,
}));
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column); emit('sort', column);
} }
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-2 sm:pl-4 lg:pl-6" class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
column="name" @click="handleSort('name')">
v-bind="sortState"
@sort="handleSort">
Name Name
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort"> <ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('client_name')">
Client Client
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
<SortableTableHeaderCell column="spent_time" v-bind="sortState" @sort="handleSort"> <ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('spent_time')">
Total Time Total Time
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
<SortableTableHeaderCell column="progress" v-bind="sortState" @sort="handleSort"> <ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('progress')">
Progress Progress
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('progress')" class="w-4 h-4" />
<SortableTableHeaderCell <ChevronUpIcon v-else-if="isChevronUp('progress')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
v-if="showBillableRate" v-if="showBillableRate"
column="billable_rate" class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
v-bind="sortState" @click="handleSort('billable_rate')">
@sort="handleSort">
Billable Rate Billable Rate
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort"> <ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status Status
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort"> <ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('visibility')">
Visibility Visibility
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
<div class="relative py-1.5 pl-3 pr-2 sm:pr-4 lg:pr-6"> <ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>
</template> </template>
<style scoped></style>

View File

@@ -88,13 +88,13 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child> <ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })"> <TableRow :href="route('projects.show', { project: project.id })">
<div <div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6"> class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div <div
:style="{ :style="{
backgroundColor: project.color, backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`, boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}" }"
class="w-3 h-3 ml-1 rounded-full"></div> class="w-3 h-3 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
@@ -155,7 +155,7 @@ const showEditProjectModal = ref(false);
</template> </template>
</div> </div>
<div <div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-2 sm:pr-4 lg:pr-6"> class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown <ProjectMoreOptionsDropdown
:project="project" :project="project"
@edit="showEditProjectModal = true" @edit="showEditProjectModal = true"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid'; import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue'; import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import { import {
@@ -81,18 +81,23 @@ function toggleNoClient() {
clientIds, clientIds,
}); });
} }
const hasActiveFilters = computed(() => {
return (
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
});
</script> </script>
<template> <template>
<DropdownMenu v-model:open="open"> <DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<!-- -ml-1.5 cancels the icon's inset inside the button so it sits flush with the toolbar's left padding --> <Button variant="ghost" size="xs" aria-label="Filter projects">
<Button <ListFilterIcon
variant="ghost" :class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
size="icon" <span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
class="-ml-1.5 h-7 w-7 flex-shrink-0"
aria-label="Filter projects">
<ListFilterIcon class="h-4 w-4 text-icon-default" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56"> <DropdownMenuContent align="start" class="w-56">

View File

@@ -9,7 +9,7 @@ const showExportModal = defineModel('show', { default: false });
function downloadCurrentExport() { function downloadCurrentExport() {
if (props.exportUrl) { if (props.exportUrl) {
window.open(props.exportUrl, '_self'); window.open(props.exportUrl, '_blank')?.focus();
} }
} }
</script> </script>

View File

@@ -1,54 +0,0 @@
<script setup lang="ts" generic="TColumn extends string">
import { computed, useAttrs } from 'vue';
import type { ClassValue } from 'clsx';
import { cn } from '@/lib/utils';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortDirection } from '@/utils/useSortableTable';
defineOptions({ inheritAttrs: false });
const props = defineProps<{
column: TColumn;
sortColumn: TColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<TColumn>;
}>();
const emit = defineEmits<{
sort: [column: TColumn];
}>();
const attrs = useAttrs();
const isSorted = computed(() => props.sortColumn === props.column);
const isChevronDown = computed(() => {
if (!isSorted.value) return false;
return props.descFirstColumns.has(props.column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
});
const cellClass = computed(() =>
cn(
'px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1',
attrs.class as ClassValue
)
);
</script>
<template>
<button type="button" :class="cellClass" @click="emit('sort', column)">
<slot></slot>
<span class="sr-only">
{{
isSorted
? `sorted ${sortDirection === 'asc' ? 'ascending' : 'descending'}`
: 'not sorted'
}}
</span>
<ChevronDownIcon v-if="isChevronDown" aria-hidden="true" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isSorted" aria-hidden="true" class="w-4 h-4" />
<span v-else aria-hidden="true" class="w-4 h-4"></span>
</button>
</template>

View File

@@ -2,7 +2,7 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid'; import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useTagsQuery } from '@/utils/useTagsQuery'; import { useTagsQuery } from '@/utils/useTagsQuery';
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue'; import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue'; import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
@@ -10,13 +10,14 @@ import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
import { canCreateTags } from '@/utils/permissions'; import { canCreateTags } from '@/utils/permissions';
import type { Tag } from '@/packages/api/src'; import type { Tag } from '@/packages/api/src';
import { import {
useSortableTable, useVueTable,
type SortableColumnDef, getCoreRowModel,
type SortDirection, getSortedRowModel,
} from '@/utils/useSortableTable'; type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name'; export type SortColumn = 'name';
export type { SortDirection } from '@/utils/useSortableTable'; export type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
createTag: (name: string) => Promise<Tag | undefined>; createTag: (name: string) => Promise<Tag | undefined>;
@@ -31,27 +32,50 @@ const emit = defineEmits<{
const { tags } = useTagsQuery(); const { tags } = useTagsQuery();
const showCreateTagModal = ref(false); const showCreateTagModal = ref(false);
const columns: SortableColumnDef<Tag, SortColumn>[] = [ const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
{ {
id: 'name', id: 'name',
accessorFn: (row: Tag) => row.name.toLowerCase(), accessorFn: (row: Tag) => row.name.toLowerCase(),
}, },
]; ];
const { const descFirstColumns = new Set<SortColumn>(
sortedRows: sortedTags, columns.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn)
descFirstColumns, );
nextDirection,
} = useSortableTable({
data: () => tags.value,
columns: () => columns,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
});
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column)); if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
} }
}
const table = useVueTable({
get data() {
return tags.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedTags = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script> </script>
<template> <template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -9,20 +9,41 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>; descFirstColumns: ReadonlySet<SortColumn>;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn]; sort: [column: SortColumn];
}>(); }>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
column="name" @click="handleSort('name')">
v-bind="props"
@sort="$emit('sort', $event)">
Name Name
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -8,62 +8,12 @@ import TaskTableHeading from '@/Components/Common/Task/TaskTableHeading.vue';
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue'; import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import { canCreateTasks } from '@/utils/permissions'; import { canCreateTasks } from '@/utils/permissions';
import type { Task } from '@/packages/api/src'; import type { Task } from '@/packages/api/src';
import {
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
export type SortColumn = 'name' | 'spent_time' | 'progress';
export type { SortDirection } from '@/utils/useSortableTable';
const props = defineProps<{ const props = defineProps<{
projectId: string; projectId: string;
tasks: Task[]; tasks: Task[];
sortColumn: SortColumn;
sortDirection: SortDirection;
}>(); }>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const columns: SortableColumnDef<Task, SortColumn>[] = [
{
id: 'name',
accessorFn: (row: Task) => row.name.toLowerCase(),
},
{
id: 'spent_time',
sortDescFirst: true,
accessorFn: (row: Task) => row.spent_time,
},
{
id: 'progress',
sortDescFirst: true,
accessorFn: (row: Task) => {
if (!row.estimated_time) return undefined;
return (row.spent_time / row.estimated_time) * 100;
},
},
];
const {
sortedRows: sortedTasks,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => props.tasks,
columns: () => columns,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column));
}
const createTask = ref(false); const createTask = ref(false);
</script> </script>
@@ -80,12 +30,8 @@ const createTask = ref(false);
1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto) 1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto)
80px; 80px;
"> ">
<TaskTableHeading <TaskTableHeading></TaskTableHeading>
:sort-column="sortColumn" <div v-if="tasks.length === 0" class="col-span-5 py-24 text-center">
:sort-direction="sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></TaskTableHeading>
<div v-if="sortedTasks.length === 0" class="col-span-5 py-24 text-center">
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon> <PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold">No tasks found</h3> <h3 class="text-text-primary font-semibold">No tasks found</h3>
<p v-if="canCreateTasks()" class="pb-5">Create your first task now!</p> <p v-if="canCreateTasks()" class="pb-5">Create your first task now!</p>
@@ -96,7 +42,7 @@ const createTask = ref(false);
>Create your First Task >Create your First Task
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="task in sortedTasks" :key="task.id"> <template v-for="task in tasks" :key="task.id">
<TaskTableRow :task="task"></TaskTableRow> <TaskTableRow :task="task"></TaskTableRow>
</template> </template>
</div> </div>

View File

@@ -1,37 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Task/TaskTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
defineEmits<{
sort: [column: SortColumn];
}>();
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
column="name"
v-bind="props"
@sort="$emit('sort', $event)">
Task Name Task Name
</SortableTableHeaderCell> </div>
<SortableTableHeaderCell column="spent_time" v-bind="props" @sort="$emit('sort', $event)"> <div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
Total Time <div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
</SortableTableHeaderCell>
<SortableTableHeaderCell column="progress" v-bind="props" @sort="$emit('sort', $event)">
Progress
</SortableTableHeaderCell>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>
</template> </template>
<style scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,21 +25,16 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template> <template>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay <AlertDialogOverlay
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"> 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" />
<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 <AlertDialogContent
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( 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', '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 props.class
) )
"> ">
<slot /> <slot />
</AlertDialogContent> </AlertDialogContent>
</div>
</AlertDialogPortal> </AlertDialogPortal>
</template> </template>

Some files were not shown because too many files have changed in this diff Show More