Compare commits

..

2 Commits

Author SHA1 Message Date
Constantin Graf
0d1d9a4ee7 Suppress expected OAuth access denial reports 2026-08-31 11:39:30 +02:00
Constantin Graf
ba778bdddf Suppress reporting for expected API exceptions 2026-08-31 11:14:23 +02:00
110 changed files with 1430 additions and 3535 deletions

View File

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

View File

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

View File

@@ -97,7 +97,7 @@ jobs:
- name: "Install dependencies in billing extension"
uses: php-actions/composer@v6
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:
working_dir: "extensions/Billing"
command: install
@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
registry: rg.fr-par.scw.cloud/solidtime
username: nologin

View File

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

View File

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

View File

@@ -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 {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
$query->where('is_placeholder', '=', false);
})
->orderBy('created_at', 'asc')
->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

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

View File

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

View File

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

View File

@@ -58,9 +58,6 @@ class UserUpdateRequest extends BaseFormRequest
'week_start' => [
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;
}
public function getSendTimeEntryStillRunningEmail(): ?bool
{
return $this->has('send_time_entry_still_running_email')
? $this->boolean('send_time_entry_still_running_email')
: null;
}
public function hasPhotoKey(): bool
{
return $this->has('photo');

View File

@@ -36,8 +36,6 @@ class UserResource extends BaseResource
'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value,
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
];
}
}

View File

@@ -38,14 +38,10 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string|null $pending_email
* @property Carbon|null $email_verified_at
* @property string|null $password
* @property string|null $remember_token
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
* @property bool $send_time_entry_still_running_email
* @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization
* @property-read string $profile_photo_url
@@ -112,7 +108,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'is_admin' => 'boolean',
'is_placeholder' => 'boolean',
'week_start' => Weekday::class,
'send_time_entry_still_running_email' => 'boolean',
];
/**
@@ -122,7 +117,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/
protected $attributes = [
'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
{
return $this->is_placeholder === false
&& in_array($this->email, config('auth.super_admins', []), true)
&& $this->hasVerifiedEmail();
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
}
public function isMemberOfOrganization(Organization $organization): bool

View File

@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Providers;
use App\Auth\ActiveUserProvider;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider
@@ -29,13 +26,6 @@ class AuthServiceProvider extends ServiceProvider
*/
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
Passport::tokensCan([
'create' => 'Create resources',

View File

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

View File

@@ -9,8 +9,11 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportService
{
@@ -22,6 +25,8 @@ class ImportService
/** @var ImporterContract $importer */
$importer = app(ImporterProvider::class)->getImporter($importerType);
$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);

View File

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

View File

@@ -13,6 +13,7 @@ use Illuminate\Support\Str;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError;
use ZipArchive;
class TogglDataImporter extends DefaultImporter
{
@@ -25,10 +26,16 @@ class TogglDataImporter extends DefaultImporter
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make();
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();
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
$zip->extractTo($temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('clients.json'))) {
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
{
public function hasAcceptedInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNotNull('accepted_at')
->exists();
}
public function hasPendingInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNull('accepted_at')
->exists();
}
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/

View File

@@ -218,16 +218,7 @@ class MemberService
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
// Reset authentication relevant properties on the placeholder user
$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->current_team_id = $member->organization_id;
$placeholderUser->save();
$member->user()->associate($placeholderUser);

View File

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

View File

@@ -100,7 +100,7 @@ return [
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', 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 FRANKENPHP_VERSION=1.11
ARG FRANKENPHP_VERSION=1.8
ARG COMPOSER_VERSION=2.8
ARG BUN_VERSION="latest"
ARG APP_ENV
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" \
CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build \
xcaddy build v2.10.0 \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
--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
@@ -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.licenses="AGPL"
ARG USER_ID=1000
ARG GROUP_ID=1000
ARG WWWUSER=1000
ARG WWWGROUP=1000
ARG TZ=UTC
ARG APP_DIR=/var/www/html
ARG APP_ENV
ARG APP_HOST
ARG DOCKER_FILES_BASE_PATH
ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \
OCTANE_SERVER=frankenphp \
TZ=${TZ} \
LANG=C.UTF-8 \
USER=laravel \
ROOT=/var/www/html \
APP_ENV=production \
COMPOSER_ALLOW_SUPERUSER=1 \
USER=octane \
ROOT=${APP_DIR} \
APP_ENV=${APP_ENV} \
COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=48 \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false \
WITH_SSR=false
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
COMPOSER_MAX_PARALLEL_HTTP=24 \
XDG_CONFIG_HOME=${APP_DIR}/.config \
XDG_DATA_HOME=${APP_DIR}/.data \
SERVER_NAME=${APP_HOST}
WORKDIR ${ROOT}
@@ -61,9 +60,6 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& 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; \
apt-get upgrade -yqq; \
apt-get install -yqq --no-install-recommends --show-progress \
@@ -72,36 +68,40 @@ RUN apt-get update; \
wget \
vim \
git \
unzip \
ncdu \
procps \
unzip \
ca-certificates \
supervisor \
libsodium-dev \
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
libbrotli-dev \
# Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \
apcu \
bz2 \
pcntl \
mbstring \
bcmath \
sockets \
pgsql \
pdo_pgsql \
opcache \
exif \
pdo_mysql \
zip \
uv \
vips \
intl \
gd \
redis \
rdkafka \
ffi \
memcached \
igbinary \
ldap \
&& apt-get -y autoremove \
&& apt-get clean \
&& 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)" \
&& case "$arch" in \
@@ -111,64 +111,106 @@ RUN arch="$(uname -m)" \
x86) _cronic_fname='supercronic-linux-386' ;; \
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
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 \
&& chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
RUN userdel --remove --force www-data \
&& groupadd --force -g ${GROUP_ID} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER}
&& groupadd --force -g ${WWWGROUP} ${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
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer
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.* ./
USER ${USER}
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 \
# --no-dev \
# --no-interaction \
# --no-autoloader \
# --no-ansi \
# --no-scripts \
# --no-progress \
# --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* ./
#
#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 \
storage/framework/{sessions,views,cache,testing} \
storage/logs \
bootstrap/cache \
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
bootstrap/cache && chmod -R a+rw storage
RUN composer dump-autoload \
--optimize \
--apcu \
--no-dev
#RUN composer install \
# --classmap-authoritative \
# --no-interaction \
# --no-ansi \
# --no-dev \
# && composer clear-cache
#RUN bun run build
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
USER ${USER}
RUN cat .env
#RUN php artisan env
EXPOSE 8000
#EXPOSE 2019
#EXPOSE 8080
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."
exit 1
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
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0

View File

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

View File

@@ -1,18 +1,65 @@
[program:octane]
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
priority = 1
autostart = true
autorestart = true
stopwaitsecs = 30
stopasgroup = true
killasgroup = true
environment = LARAVEL_OCTANE = "1"
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
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]
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
upload_max_filesize = 100M
expose_php = 0
realpath_cache_size = 32M
realpath_cache_ttl = 720
memory_limit = 256M
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
register_argc_argv = 0
date.timezone = ${TZ:-UTC}
@@ -12,24 +11,18 @@ date.timezone = ${TZ:-UTC}
[Opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256
opcache.memory_consumption = 256M
opcache.use_cwd = 0
opcache.save_comments = 1
opcache.max_file_size = 0
opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16
opcache.enable_file_override = 1
opcache.file_cache_consistency_checks = 0
opcache.file_cache = /tmp/opcache-file-cache
[JIT]
opcache.jit_buffer_size = 128M
opcache.jit = tracing
opcache.jit_hot_loop = 16
opcache.jit_hot_func = 32
opcache.jit_hot_return = 4
opcache.jit = function
opcache.jit_prof_threshold = 0.001
opcache.jit_max_root_traces = 2048
opcache.jit_max_side_traces = 256

View File

@@ -1,6 +1,8 @@
[supervisord]
nodaemon = true
user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid
[supervisorctl]
@@ -8,4 +10,4 @@ user = %(ENV_USER)s
port = 127.0.0.1:9001
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

View File

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

View File

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

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

@@ -20,12 +20,6 @@ function profileInformationForm(page: Page) {
.locator('xpath=ancestor::*[descendant::form][1]');
}
function notificationSettingsForm(page: Page) {
return page
.getByRole('heading', { name: 'Notifications', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]');
}
async function saveProfileForm(page: Page): Promise<void> {
const form = profileInformationForm(page);
await form.getByRole('button', { name: 'Save' }).click();
@@ -56,22 +50,6 @@ test('week-start change persists across reload', async ({ page }) => {
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
});
test('still-running email notification setting persists across reload', async ({ page }) => {
await goToProfilePage(page);
const form = notificationSettingsForm(page);
const checkbox = form.getByLabel('Still-running time entry reminders');
await expect(checkbox).toBeChecked();
await checkbox.uncheck();
await form.getByRole('button', { name: 'Save' }).click();
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
await page.reload();
await expect(
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
).not.toBeChecked();
});
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
page,
}) => {

View File

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

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

View File

@@ -7,7 +7,7 @@ import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.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 { useProjectsQuery } from '@/utils/useProjectsQuery';
import {

View File

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

View File

@@ -6,11 +6,17 @@ import { computed, ref, watch } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.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 =
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
| 'name'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';

View File

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

View File

@@ -63,7 +63,7 @@ const max = computed(() => {
});
const backgroundColor = useCssVariable('--theme-color-card-background');
const borderColor = useCssVariable('--color-border-secondary');
const borderColor = useCssVariable('--color-border');
const labelColor = useCssVariable('--color-text-secondary');
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.secondHalf), label: workLabel },
...plan.shifted.map((shift) => ({
times: moved(
req.otherEntries.find((e) => e.id === shift.id)!,
shift
),
times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift),
label: props.entryLabel(shift.id),
})),
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,103 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Checkbox } from '@/packages/ui/src';
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
} from '@/packages/ui/src/field';
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
import { useUpdateUserMutation, useUserQuery } from '@/utils/useUserQuery';
const { user } = useUserQuery();
const updateUser = useUpdateUserMutation();
const sendTimeEntryStillRunningEmail = ref(true);
const recentlySaved = ref(false);
watch(
user,
(currentUser, previousUser) => {
if (currentUser && previousUser === undefined) {
sendTimeEntryStillRunningEmail.value = currentUser.send_time_entry_still_running_email;
}
},
{ immediate: true }
);
const isUserLoaded = computed(() => user.value !== undefined);
const isSaveDisabled = computed(() => !isUserLoaded.value || updateUser.isPending.value);
const fieldErrors = computed<Record<string, string>>(() =>
getApiValidationFieldErrors(updateUser.error.value)
);
async function save() {
if (isSaveDisabled.value || !user.value) return;
if (sendTimeEntryStillRunningEmail.value === user.value.send_time_entry_still_running_email) {
flashSaved();
return;
}
try {
const updatedUser = await updateUser.mutateAsync({
userId: user.value.id,
body: {
send_time_entry_still_running_email: sendTimeEntryStillRunningEmail.value,
},
});
sendTimeEntryStillRunningEmail.value = updatedUser.send_time_entry_still_running_email;
flashSaved();
} catch {
// 422: field errors render below. Other errors: toast handled in the mutation.
}
}
function flashSaved() {
recentlySaved.value = true;
setTimeout(() => (recentlySaved.value = false), 2000);
}
</script>
<template>
<FormSection @submitted="save">
<template #title>Notifications</template>
<template #description>Choose which email notifications you want to receive.</template>
<template #form>
<div class="col-span-6 sm:col-span-4">
<Field orientation="horizontal">
<Checkbox
id="send_time_entry_still_running_email"
v-model:checked="sendTimeEntryStillRunningEmail"
:disabled="!isUserLoaded" />
<FieldContent>
<FieldLabel for="send_time_entry_still_running_email">
Still-running time entry reminders
</FieldLabel>
<FieldDescription>
Email me when a time entry has been running for more than 8 hours.
</FieldDescription>
</FieldContent>
</Field>
<FieldError v-if="fieldErrors.send_time_entry_still_running_email">
{{ fieldErrors.send_time_entry_still_running_email }}
</FieldError>
</div>
</template>
<template #actions>
<ActionMessage :on="recentlySaved" class="me-3">Saved.</ActionMessage>
<PrimaryButton :class="{ 'opacity-25': isSaveDisabled }" :disabled="isSaveDisabled">
Save
</PrimaryButton>
</template>
</FormSection>
</template>

View File

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

View File

@@ -9,7 +9,7 @@ import {
ArrowDownTrayIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import Pagination from '@/packages/ui/src/Pagination.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import {
DropdownMenu,
DropdownMenuContent,

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.23",
"version": "0.0.22",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",

View File

@@ -11,7 +11,6 @@ import {
CommandShortcut,
} from '../command';
import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import type {
CommandPaletteCommand,
CommandPaletteGroup,
@@ -37,8 +36,6 @@ const emit = defineEmits<{
select: [command: CommandPaletteCommand | EntitySearchResult];
}>();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
// Non-empty groups for rendering
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
@@ -74,9 +71,7 @@ watch(open, (isOpen) => {
)
">
<DialogContent
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 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"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus">
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 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">
<CommandRoot
v-model:search-term="searchTerm"
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import {
DialogContent,
type DialogContentEmits,
@@ -21,10 +20,6 @@ const delegatedProps = computed(() => {
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
// Forwarded consumer listeners run first, so a consumer can still take over
// by calling preventDefault() on close-auto-focus.
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
</script>
<template>
@@ -41,9 +36,7 @@ const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus">
">
<slot />
</DialogContent>
</div>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import { X } from '@lucide/vue';
import {
DialogClose,
@@ -23,10 +22,6 @@ const delegatedProps = computed(() => {
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
// Forwarded consumer listeners run first, so a consumer can still take over
// by calling preventDefault() on close-auto-focus.
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
</script>
<template>
@@ -41,8 +36,6 @@ const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
)
"
v-bind="forwarded"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;

View File

@@ -32,7 +32,6 @@ import InputLabel from './Input/InputLabel.vue';
import TextInput from './Input/TextInput.vue';
import LoadingSpinner from './LoadingSpinner.vue';
import Modal from './Modal.vue';
import Pagination from './Pagination.vue';
import ProjectBadge from './Project/ProjectBadge.vue';
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
@@ -58,16 +57,6 @@ import {
CalendarNextButton,
CalendarPrevButton,
} from './calendar/index';
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
} from './combobox/index';
import { CommandPalette } from './CommandPalette/index';
import {
ContextMenu,
@@ -187,14 +176,6 @@ export {
CardTitle,
Checkbox,
color,
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
CommandPalette,
ContextMenu,
ContextMenuCheckboxItem,
@@ -258,7 +239,6 @@ export {
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
Pagination,
Popover,
PopoverAnchor,
PopoverContent,

View File

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

View File

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

View File

@@ -1,87 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { useDialogFocusRestore } from './useDialogFocusRestore';
function closeEvent() {
return new CustomEvent('focusScope.autoFocusOnUnmount', { cancelable: true });
}
describe('useDialogFocusRestore', () => {
it('restores focus to the element focused when the dialog opened', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.blur();
const event = closeEvent();
onCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
vi.runAllTimers();
expect(document.activeElement).toBe(button);
button.remove();
vi.useRealTimers();
});
it('focuses nothing when the dialog was opened with nothing focused', () => {
vi.useFakeTimers();
const stale = document.createElement('button');
document.body.appendChild(stale);
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
// First open from the button, then close
stale.focus();
onOpenAutoFocus();
onCloseAutoFocus(closeEvent());
vi.runAllTimers();
stale.blur();
// Second open from the body must not refocus the stale button
onOpenAutoFocus();
const event = closeEvent();
onCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
stale.remove();
vi.useRealTimers();
});
it('does not restore focus to an element that was removed', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.remove();
onCloseAutoFocus(closeEvent());
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
vi.useRealTimers();
});
it('leaves control to a consumer that already prevented the event', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.blur();
const event = closeEvent();
event.preventDefault();
onCloseAutoFocus(event);
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
button.remove();
vi.useRealTimers();
});
});

View File

@@ -1,41 +0,0 @@
/**
* Restores focus to the element that was focused when a dialog opened.
*
* reka-ui remembers the active element at content mount as the dialog's
* "trigger" (only when it is not the body) and refocuses it on every close,
* but it never clears that value. A dialog opened while nothing is focused
* (e.g. the command palette via Cmd+K from the body) therefore refocuses
* whatever triggered a *previous* open. Bind these handlers to
* `DialogContent`'s `open-auto-focus` / `close-auto-focus` events to restore
* exactly the previously focused element, or nothing.
*
* A consumer handler that already called `preventDefault()` on
* `close-auto-focus` keeps control; this composable then does nothing.
*/
export function useDialogFocusRestore() {
let previouslyFocused: HTMLElement | null = null;
function onOpenAutoFocus() {
const active = document.activeElement;
previouslyFocused =
active instanceof HTMLElement && active !== document.body ? active : null;
}
function onCloseAutoFocus(event: Event) {
const target = previouslyFocused;
previouslyFocused = null;
if (event.defaultPrevented) {
return;
}
// Prevents both FocusScope's default restore and reka-ui's trigger refocus
event.preventDefault();
// Same tick reka-ui uses, so the dialog content is fully gone first
setTimeout(() => {
if (target?.isConnected) {
target.focus({ preventScroll: true });
}
}, 0);
}
return { onOpenAutoFocus, onCloseAutoFocus };
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { api } from '@/packages/api/src';
import type { TimeEntry } from '@/packages/api/src';
import dayjs, { Dayjs } from 'dayjs';
@@ -57,6 +57,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
deep: true,
});
@@ -88,12 +89,23 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
try {
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
if (timeEntriesResponse?.data) {
currentTimeEntry.value = timeEntriesResponse.data;
} else if (currentTimeEntry.value.id !== '') {
// No active time entry on server
// Only reset if we had a previously started timer (has an ID)
// Don't reset if user is preparing a new time entry (no ID yet)
currentTimeEntry.value = { ...emptyTimeEntry };
if (timeEntriesResponse.data) {
currentTimeEntry.value = timeEntriesResponse.data;
if (
currentTimeEntry.value.start !== '' &&
currentTimeEntry.value.end === null
) {
startLiveTimer();
}
} else {
// No active time entry on server
// Only reset if we had a previously started timer (has an ID)
// Don't reset if user is preparing a new time entry (no ID yet)
if (currentTimeEntry.value.id !== '') {
currentTimeEntry.value = { ...emptyTimeEntry };
stopLiveTimer();
}
}
}
} catch {
// API error (e.g., 404 when no active time entry)
@@ -101,6 +113,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
// Don't reset if user is preparing a new time entry (no ID yet)
if (currentTimeEntry.value.id !== '') {
currentTimeEntry.value = { ...emptyTimeEntry };
stopLiveTimer();
}
}
} else {
@@ -281,18 +294,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
return isActive.value && currentTimeEntry.value.type === 'break';
});
watch(
isActive,
(active) => {
if (active) {
startLiveTimer();
} else {
stopLiveTimer();
}
},
{ immediate: true }
);
async function setActiveState(newState: boolean) {
if (newState) {
startLiveTimer();

View File

@@ -6,7 +6,6 @@ namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
@@ -44,43 +43,4 @@ class PasswordConfirmationTest extends TestCase
$response->assertSessionHasErrors();
}
public function test_password_can_be_confirmed_if_a_placeholder_user_with_the_same_email_exists(): void
{
// Arrange
// Placeholders created by an import have no password at all. The placeholder is created
// first so that it would be returned by an unordered lookup by email.
$email = 'shared@example.com';
User::factory()->placeholder()->create(['email' => $email, 'password' => null]);
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('secret-password')]);
// Act
$response = $this->actingAs($user)->post('/user/confirm-password', [
'password' => 'secret-password',
]);
// Assert
$response->assertRedirect();
$response->assertSessionHasNoErrors();
$this->assertTrue($this->app['session']->has('auth.password_confirmed_at'));
}
public function test_password_confirmation_ignores_the_password_of_a_placeholder_user_with_the_same_email(): void
{
// Arrange
// Placeholders created by removing a member copy the password hash as of the removal,
// so the placeholder holds a password that the real user has since replaced.
$email = 'shared@example.com';
User::factory()->placeholder()->create(['email' => $email, 'password' => Hash::make('outdated-password')]);
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('current-password')]);
// Act
$response = $this->actingAs($user)->post('/user/confirm-password', [
'password' => 'outdated-password',
]);
// Assert
$response->assertSessionHasErrors();
$this->assertFalse($this->app['session']->has('auth.password_confirmed_at'));
}
}

View File

@@ -7,7 +7,6 @@ namespace Tests\Feature;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
@@ -94,62 +93,4 @@ class PasswordResetTest extends TestCase
return true;
});
}
public function test_password_reset_targets_the_real_user_when_a_placeholder_user_with_the_same_email_exists(): void
{
Notification::fake();
// The placeholder is created first so that it would be returned by an unordered lookup by email
$email = 'shared@example.com';
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
$user = User::factory()->create(['email' => $email]);
$placeholderPasswordBefore = $placeholder->password;
$response = $this->post('/forgot-password', [
'email' => $email,
]);
$response->assertSessionHasNoErrors();
Notification::assertNotSentTo($placeholder, ResetPassword::class);
Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($email) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $email,
'password' => 'new-password-123',
'password_confirmation' => 'new-password-123',
]);
$response->assertSessionHasNoErrors();
return true;
});
$placeholder->refresh();
$user->refresh();
$this->assertSame($placeholderPasswordBefore, $placeholder->password);
$this->assertTrue(Hash::check('new-password-123', $user->password));
$response = $this->post('/login', [
'email' => $email,
'password' => 'new-password-123',
]);
$response->assertSessionHasNoErrors();
$this->assertAuthenticatedAs($user);
}
public function test_password_reset_link_is_not_sent_if_only_a_placeholder_user_with_the_email_exists(): void
{
Notification::fake();
$placeholder = User::factory()->placeholder()->create();
$response = $this->post('/forgot-password', [
'email' => $placeholder->email,
]);
$response->assertSessionHasErrors('email');
Notification::assertNothingSent();
}
}

View File

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

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Auth;
use App\Auth\ActiveUserProvider;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(ActiveUserProvider::class)]
class ActiveUserProviderTest extends TestCaseWithDatabase
{
public function test_password_broker_uses_the_active_user_provider(): void
{
// Act
$brokerProvider = Auth::createUserProvider(config('auth.passwords.users.provider'));
// Assert
$this->assertInstanceOf(ActiveUserProvider::class, $brokerProvider);
}
public function test_api_guard_uses_the_active_user_provider(): void
{
// Act
$guardProvider = Auth::createUserProvider(config('auth.guards.api.provider'));
// Assert
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
}
public function test_web_guard_uses_the_active_user_provider(): void
{
// Act
$guardProvider = Auth::createUserProvider(config('auth.guards.web.provider'));
// Assert
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
}
public function test_retrieve_by_credentials_ignores_placeholder_users_with_the_same_email(): void
{
// Arrange
$email = 'shared@example.com';
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
$user = User::factory()->create(['email' => $email]);
$provider = Auth::createUserProvider('users');
// Act
$result = $provider->retrieveByCredentials(['email' => $email]);
// Assert
$this->assertInstanceOf(User::class, $result);
$this->assertTrue($user->is($result));
$this->assertFalse($placeholder->is($result));
}
public function test_retrieve_by_credentials_returns_null_if_only_a_placeholder_user_exists(): void
{
// Arrange
$email = 'placeholder-only@example.com';
User::factory()->placeholder()->create(['email' => $email]);
$provider = Auth::createUserProvider('users');
// Act
$result = $provider->retrieveByCredentials(['email' => $email]);
// Assert
$this->assertNull($result);
}
public function test_retrieve_by_id_returns_null_for_placeholder_users(): void
{
// Arrange
$placeholder = User::factory()->placeholder()->create();
$user = User::factory()->create();
$provider = Auth::createUserProvider('users');
// Act
$placeholderResult = $provider->retrieveById($placeholder->getKey());
$userResult = $provider->retrieveById($user->getKey());
// Assert
$this->assertNull($placeholderResult);
$this->assertInstanceOf(User::class, $userResult);
$this->assertTrue($user->is($userResult));
}
}

View File

@@ -159,26 +159,4 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
$this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", $output);
}
public function test_does_not_send_emails_to_users_who_disabled_them(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user->user->send_time_entry_still_running_email = false;
$user->user->save();
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
'start' => Carbon::now()->subHours(8)->subSecond(),
'end' => null,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');
// Assert
Mail::assertNothingOutgoing();
$this->assertNull($timeEntry->fresh()->still_active_email_sent_at);
$this->assertSame(Command::SUCCESS, $exitCode);
$this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", Artisan::output());
}
}

View File

@@ -97,29 +97,6 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_import_fails_if_data_exceeds_maximum_size(): void
{
// Arrange
config(['import.max_data_size' => 16]);
$user = $this->createUserWithPermission([
'import',
]);
$this->mock(ImportService::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('import');
});
Passport::actingAs($user->user);
// Act
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [
'type' => 'toggl_time_entries',
'data' => base64_encode(str_repeat('a', 15)),
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['data']);
}
public function test_import_return_error_message_if_import_fails(): void
{
// Arrange

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