mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-14 16:53:17 +01:00
Compare commits
1 Commits
feature/ad
...
c45d6e2f5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45d6e2f5e |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ class ImportRequest extends BaseFormRequest
|
|||||||
'data' => [
|
'data' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'max:'.config('import.max_data_size'),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string|null $pending_email
|
* @property string|null $pending_email
|
||||||
* @property Carbon|null $email_verified_at
|
* @property Carbon|null $email_verified_at
|
||||||
* @property string|null $password
|
* @property string|null $password
|
||||||
* @property string|null $remember_token
|
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
* @property string|null $two_factor_recovery_codes
|
|
||||||
* @property Carbon|null $two_factor_confirmed_at
|
|
||||||
* @property string $timezone
|
* @property string $timezone
|
||||||
* @property bool $is_placeholder
|
* @property bool $is_placeholder
|
||||||
* @property Weekday $week_start
|
* @property Weekday $week_start
|
||||||
@@ -153,9 +150,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return $this->is_placeholder === false
|
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
||||||
&& in_array($this->email, config('auth.super_admins', []), true)
|
|
||||||
&& $this->hasVerifiedEmail();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isMemberOfOrganization(Organization $organization): bool
|
public function isMemberOfOrganization(Organization $organization): bool
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Auth\ActiveUserProvider;
|
|
||||||
use App\Models\Passport\AuthCode;
|
use App\Models\Passport\AuthCode;
|
||||||
use App\Models\Passport\Client;
|
use App\Models\Passport\Client;
|
||||||
use App\Models\Passport\RefreshToken;
|
use App\Models\Passport\RefreshToken;
|
||||||
use App\Models\Passport\Token;
|
use App\Models\Passport\Token;
|
||||||
use Illuminate\Contracts\Foundation\Application;
|
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
class AuthServiceProvider extends ServiceProvider
|
||||||
@@ -29,13 +26,6 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// Replaces the built-in eloquent user provider, so that no authentication flow can
|
|
||||||
// resolve a placeholder user. The driver name is kept, because Passport recognizes
|
|
||||||
// only providers that are configured with the driver "eloquent".
|
|
||||||
Auth::provider('eloquent', function (Application $app, array $config): ActiveUserProvider {
|
|
||||||
return new ActiveUserProvider($app->make('hash'), $config['model']);
|
|
||||||
});
|
|
||||||
|
|
||||||
// define scopes for passport tokens
|
// define scopes for passport tokens
|
||||||
Passport::tokensCan([
|
Passport::tokensCan([
|
||||||
'create' => 'Create resources',
|
'create' => 'Create resources',
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ use App\Service\Import\Importers\ImporterContract;
|
|||||||
use App\Service\Import\Importers\ImporterProvider;
|
use App\Service\Import\Importers\ImporterProvider;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\ReportDto;
|
use App\Service\Import\Importers\ReportDto;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ImportService
|
class ImportService
|
||||||
{
|
{
|
||||||
@@ -22,6 +25,8 @@ class ImportService
|
|||||||
/** @var ImporterContract $importer */
|
/** @var ImporterContract $importer */
|
||||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
|
Storage::disk(config('filesystems.default'))
|
||||||
|
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
|
||||||
|
|
||||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Illuminate\Support\Str;
|
|||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
class SolidtimeImporter extends DefaultImporter
|
class SolidtimeImporter extends DefaultImporter
|
||||||
{
|
{
|
||||||
@@ -33,10 +34,16 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$temporaryDirectoryZip = null;
|
$temporaryDirectoryZip = null;
|
||||||
$temporaryDirectory = null;
|
$temporaryDirectory = null;
|
||||||
try {
|
try {
|
||||||
|
$zip = new ZipArchive;
|
||||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||||
|
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||||
|
if ($res !== true) {
|
||||||
|
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||||
|
}
|
||||||
$temporaryDirectory = TemporaryDirectory::make();
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
$zip->extractTo($temporaryDirectory->path());
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
||||||
throw new ImportException('File "meta.json" missing in ZIP');
|
throw new ImportException('File "meta.json" missing in ZIP');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Illuminate\Support\Str;
|
|||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ValueError;
|
use ValueError;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
class TogglDataImporter extends DefaultImporter
|
class TogglDataImporter extends DefaultImporter
|
||||||
{
|
{
|
||||||
@@ -25,10 +26,16 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
$temporaryDirectoryZip = null;
|
$temporaryDirectoryZip = null;
|
||||||
$temporaryDirectory = null;
|
$temporaryDirectory = null;
|
||||||
try {
|
try {
|
||||||
|
$zip = new ZipArchive;
|
||||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||||
|
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||||
|
if ($res !== true) {
|
||||||
|
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||||
|
}
|
||||||
$temporaryDirectory = TemporaryDirectory::make();
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
$zip->extractTo($temporaryDirectory->path());
|
||||||
|
$zip->close();
|
||||||
if (! file_exists($temporaryDirectory->path('clients.json'))) {
|
if (! file_exists($temporaryDirectory->path('clients.json'))) {
|
||||||
throw new ImportException('File "clients.json" missing in ZIP');
|
throw new ImportException('File "clients.json" missing in ZIP');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -218,16 +218,7 @@ class MemberService
|
|||||||
|
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
$placeholderUser->is_placeholder = true;
|
$placeholderUser->is_placeholder = true;
|
||||||
// Reset authentication relevant properties on the placeholder user
|
$placeholderUser->current_team_id = $member->organization_id;
|
||||||
$placeholderUser->password = null;
|
|
||||||
$placeholderUser->remember_token = null;
|
|
||||||
$placeholderUser->two_factor_secret = null;
|
|
||||||
$placeholderUser->two_factor_recovery_codes = null;
|
|
||||||
$placeholderUser->two_factor_confirmed_at = null;
|
|
||||||
$placeholderUser->email_verified_at = null;
|
|
||||||
$placeholderUser->pending_email = null;
|
|
||||||
$placeholderUser->current_team_id = null;
|
|
||||||
$placeholderUser->profile_photo_path = null;
|
|
||||||
$placeholderUser->save();
|
$placeholderUser->save();
|
||||||
|
|
||||||
$member->user()->associate($placeholderUser);
|
$member->user()->associate($placeholderUser);
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"brick/money": "^0.10.0",
|
"brick/money": "^0.10.0",
|
||||||
"datomatic/laravel-enum-helper": "^2.0.0",
|
"datomatic/laravel-enum-helper": "^2.0.0",
|
||||||
"dedoc/scramble": "^0.12.2",
|
"dedoc/scramble": "^0.13.43",
|
||||||
"filament/filament": "^3.2",
|
"filament/filament": "^3.2",
|
||||||
"flowframe/laravel-trend": "^0.4.0",
|
"flowframe/laravel-trend": "^0.5.0",
|
||||||
"gotenberg/gotenberg-php": "^2.8",
|
"gotenberg/gotenberg-php": "^2.8",
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
"inertiajs/inertia-laravel": "^2.0.3",
|
"inertiajs/inertia-laravel": "^2.0.3",
|
||||||
|
|||||||
1461
composer.lock
generated
1461
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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),
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
|
|||||||
test('test that calendar navigation buttons work', async ({ page }) => {
|
test('test that calendar navigation buttons work', async ({ page }) => {
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
|
||||||
|
|
||||||
// Navigate between named periods.
|
// Click the "next" button to navigate forward
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// Click the "prev" button to navigate back
|
||||||
await page.getByRole('button', { name: 'Previous' }).click();
|
await page.getByRole('button', { name: 'Previous' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('Last Week');
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
|
||||||
|
|
||||||
// Navigate forward first, then use the range button to return to today.
|
// Navigate forward first, then click today
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
|
|
||||||
await page.getByRole('button', { name: 'today' }).click();
|
await page.getByRole('button', { name: 'today' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||||||
|
|||||||
@@ -18,25 +18,6 @@ import {
|
|||||||
// Each test registers a new user and creates test data via API
|
// Each test registers a new user and creates test data via API
|
||||||
test.describe.configure({ timeout: 30000 });
|
test.describe.configure({ timeout: 30000 });
|
||||||
|
|
||||||
test('date range picker labels and navigates reporting periods', async ({ page }) => {
|
|
||||||
await goToReporting(page);
|
|
||||||
|
|
||||||
const range = page.getByTestId('date_range_picker_display');
|
|
||||||
const previous = page.getByTestId('date_range_picker_previous');
|
|
||||||
const next = page.getByTestId('date_range_picker_next');
|
|
||||||
|
|
||||||
await expect(range).toContainText('Last 14 Days');
|
|
||||||
await expect(next).toBeDisabled();
|
|
||||||
|
|
||||||
await Promise.all([waitForReportingUpdate(page), previous.click()]);
|
|
||||||
await expect(range).not.toContainText('Last 14 Days');
|
|
||||||
|
|
||||||
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
|
|
||||||
// does not necessarily trigger another aggregate request.
|
|
||||||
await next.click();
|
|
||||||
await expect(range).toContainText('Last 14 Days');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Project Multiselect Dropdown Tests
|
// Project Multiselect Dropdown Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
|
|||||||
test('can navigate forward and return to current week', async ({ page }) => {
|
test('can navigate forward and return to current week', async ({ page }) => {
|
||||||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||||||
|
|
||||||
// Should show "This Week"
|
// Should show "This week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||||||
|
|
||||||
// Go to next week — the text assertions below auto-retry until the
|
// Go to next week — the text assertions below auto-retry until the
|
||||||
// header label flips.
|
// header label flips.
|
||||||
await page.getByTestId('timesheet_next_week').click();
|
await page.getByTestId('timesheet_next_week').click();
|
||||||
|
|
||||||
// Should no longer show "This Week"
|
// Should no longer show "This week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
|
||||||
|
|
||||||
// Go back to this week
|
// Go back to this week
|
||||||
await page.getByTestId('timesheet_week_display').click();
|
await page.getByTestId('timesheet_week_display').click();
|
||||||
|
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
isCurrentWeek: boolean;
|
||||||
weekNumber: number;
|
weekNumber: number;
|
||||||
rangeLabel: string;
|
weekRangeDisplay: string;
|
||||||
weekTotalFormatted: string;
|
weekTotalFormatted: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -17,16 +19,33 @@ defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
||||||
<!-- Left: Week navigation -->
|
<!-- Left: Week navigation -->
|
||||||
<DateRangeNavigator
|
<div class="flex items-center gap-2">
|
||||||
:label="rangeLabel"
|
<Button
|
||||||
:secondary-label="`W${weekNumber}`"
|
variant="outline"
|
||||||
previous-test-id="timesheet_prev_week"
|
size="icon"
|
||||||
trigger-test-id="timesheet_week_display"
|
class="h-8 w-8"
|
||||||
next-test-id="timesheet_next_week"
|
data-testid="timesheet_prev_week"
|
||||||
trigger-aria-label="Return to current week"
|
@click="$emit('previous')">
|
||||||
@previous="$emit('previous')"
|
<ChevronLeftIcon class="h-4 w-4" />
|
||||||
@next="$emit('next')"
|
</Button>
|
||||||
@select="$emit('current')" />
|
<button
|
||||||
|
data-testid="timesheet_week_display"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
|
||||||
|
@click="$emit('current')">
|
||||||
|
<CalendarIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
<span v-if="isCurrentWeek">This week</span>
|
||||||
|
<span v-else>{{ weekRangeDisplay }}</span>
|
||||||
|
<span class="text-text-tertiary">· W{{ weekNumber }}</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
data-testid="timesheet_next_week"
|
||||||
|
@click="$emit('next')">
|
||||||
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right: Week total -->
|
<!-- Right: Week total -->
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
||||||
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
|
|
||||||
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
||||||
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
||||||
@@ -45,6 +44,7 @@ const {
|
|||||||
weekEnd,
|
weekEnd,
|
||||||
weekDays,
|
weekDays,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
|
isCurrentWeek,
|
||||||
todayDate,
|
todayDate,
|
||||||
goToPreviousWeek,
|
goToPreviousWeek,
|
||||||
goToNextWeek,
|
goToNextWeek,
|
||||||
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
|
|||||||
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const weekRangeLabel = computed(() =>
|
const weekRangeDisplay = computed(() => {
|
||||||
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day'))
|
const start = weekStart.value;
|
||||||
);
|
const end = start.add(6, 'day');
|
||||||
|
return start.month() === end.month()
|
||||||
|
? `${start.format('MMM D')} - ${end.format('D')}`
|
||||||
|
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Cell / row mutation handlers ──────────────────────────────────
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
const {
|
const {
|
||||||
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
|||||||
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
||||||
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimesheetHeader
|
<TimesheetHeader
|
||||||
|
:is-current-week="isCurrentWeek"
|
||||||
:week-number="weekNumber"
|
:week-number="weekNumber"
|
||||||
:range-label="weekRangeLabel"
|
:week-range-display="weekRangeDisplay"
|
||||||
:week-total-formatted="weekTotalFormatted"
|
:week-total-formatted="weekTotalFormatted"
|
||||||
@previous="goToPreviousWeek"
|
@previous="goToPreviousWeek"
|
||||||
@next="goToNextWeek"
|
@next="goToNextWeek"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Button } from '..';
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||||
import { Tabs, TabsList } from '../tabs';
|
import { Tabs, TabsList } from '../tabs';
|
||||||
import TabBarItem from '../TabBar/TabBarItem.vue';
|
import TabBarItem from '../TabBar/TabBarItem.vue';
|
||||||
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
|
|
||||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
rangeLabel: string;
|
viewTitle: string;
|
||||||
activeView: string;
|
activeView: string;
|
||||||
settings: CalendarSettings;
|
settings: CalendarSettings;
|
||||||
}>();
|
}>();
|
||||||
@@ -22,13 +23,31 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
||||||
<DateRangeNavigator
|
<!-- Left: Navigation -->
|
||||||
:label="rangeLabel"
|
<div class="flex items-center gap-1">
|
||||||
trigger-test-id="calendar-title"
|
<Button
|
||||||
trigger-aria-label="today"
|
variant="outline"
|
||||||
@previous="emit('prev')"
|
size="sm"
|
||||||
@next="emit('next')"
|
class="h-8 w-8 p-0"
|
||||||
@select="emit('today')" />
|
aria-label="Previous"
|
||||||
|
@click="emit('prev')">
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
aria-label="Next"
|
||||||
|
@click="emit('next')">
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Title -->
|
||||||
|
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
|
||||||
|
viewTitle
|
||||||
|
}}</span>
|
||||||
|
|
||||||
<!-- Right: View switcher + Settings -->
|
<!-- Right: View switcher + Settings -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
activeView,
|
activeView,
|
||||||
viewDays,
|
viewDays,
|
||||||
rangeLabel,
|
viewTitle,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
|
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
:range-label="rangeLabel"
|
:view-title="viewTitle"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:settings="calendarSettings"
|
:settings="calendarSettings"
|
||||||
@prev="handlePrev"
|
@prev="handlePrev"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { getLocalizedDayJs } from '../utils/time';
|
import { getLocalizedDayJs } from '../utils/time';
|
||||||
import { getWeekStartDayNumber } from '../utils/settings';
|
import { getWeekStartDayNumber } from '../utils/settings';
|
||||||
import { getDateRangeLabel } from '../utils/dateRange';
|
|
||||||
|
|
||||||
export function useCalendarNavigation(callbacks: {
|
export function useCalendarNavigation(callbacks: {
|
||||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||||
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
return days;
|
return days;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rangeLabel = computed(() => {
|
const viewTitle = computed(() => {
|
||||||
|
if (activeView.value === 'timeGridDay') {
|
||||||
|
return currentDate.value.format('MMMM YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
const days = viewDays.value;
|
const days = viewDays.value;
|
||||||
if (days.length === 0) return '';
|
if (days.length === 0) return '';
|
||||||
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
|
|
||||||
|
const first = days[0]!;
|
||||||
|
const last = days[days.length - 1]!;
|
||||||
|
|
||||||
|
if (first.year() !== last.year()) {
|
||||||
|
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
if (first.month() !== last.month()) {
|
||||||
|
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
return first.format('MMMM YYYY');
|
||||||
});
|
});
|
||||||
|
|
||||||
function emitDatesChange() {
|
function emitDatesChange() {
|
||||||
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
activeView,
|
activeView,
|
||||||
currentDate,
|
currentDate,
|
||||||
viewDays,
|
viewDays,
|
||||||
rangeLabel,
|
viewTitle,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
|
|
||||||
import { Button } from '../Buttons';
|
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
label: string;
|
|
||||||
secondaryLabel?: string;
|
|
||||||
previousDisabled?: boolean;
|
|
||||||
nextDisabled?: boolean;
|
|
||||||
previousTestId?: string;
|
|
||||||
triggerTestId?: string;
|
|
||||||
nextTestId?: string;
|
|
||||||
triggerAriaLabel?: string;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
secondaryLabel: undefined,
|
|
||||||
previousTestId: undefined,
|
|
||||||
triggerTestId: undefined,
|
|
||||||
nextTestId: undefined,
|
|
||||||
triggerAriaLabel: undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
previous: [];
|
|
||||||
next: [];
|
|
||||||
select: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const triggerClass =
|
|
||||||
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
|
|
||||||
role="group"
|
|
||||||
aria-label="Date range navigation">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
|
||||||
aria-label="Previous"
|
|
||||||
:disabled="previousDisabled"
|
|
||||||
:data-testid="previousTestId"
|
|
||||||
@click="emit('previous')">
|
|
||||||
<ChevronLeft class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<slot name="trigger" :trigger-class="triggerClass">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
:class="triggerClass"
|
|
||||||
:aria-label="triggerAriaLabel"
|
|
||||||
:data-testid="triggerTestId"
|
|
||||||
@click="emit('select')">
|
|
||||||
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
|
|
||||||
<span class="min-w-0 truncate">{{ label }}</span>
|
|
||||||
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
|
|
||||||
· {{ secondaryLabel }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</slot>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
|
||||||
aria-label="Next"
|
|
||||||
:disabled="nextDisabled"
|
|
||||||
:data-testid="nextTestId"
|
|
||||||
@click="emit('next')">
|
|
||||||
<ChevronRight class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
|
|||||||
import { CalendarIcon } from '@lucide/vue';
|
import { CalendarIcon } from '@lucide/vue';
|
||||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import DateRangeNavigator from './DateRangeNavigator.vue';
|
import {
|
||||||
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
|
getDayJsInstance,
|
||||||
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
|
getLocalizedDayJs,
|
||||||
|
firstDayIndex,
|
||||||
|
type WeekStartDay,
|
||||||
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { type Organization } from '@/packages/api/src';
|
import { type Organization } from '@/packages/api/src';
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
|
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||||
|
|
||||||
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<{
|
||||||
defineProps<{
|
start: string;
|
||||||
start: string;
|
end: string;
|
||||||
end: string;
|
}>();
|
||||||
allowFuture?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ allowFuture: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:start', value: string): void;
|
(e: 'update:start', value: string): void;
|
||||||
@@ -35,13 +35,10 @@ interface CalendarDateRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const today = computed(() => {
|
const today = computed(() => {
|
||||||
const now = getLocalizedDayJs();
|
const now = getDayJsInstance()();
|
||||||
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
||||||
});
|
});
|
||||||
|
|
||||||
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
|
|
||||||
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
|
|
||||||
|
|
||||||
const modelValue = computed<CalendarDateRange>({
|
const modelValue = computed<CalendarDateRange>({
|
||||||
get: () => ({
|
get: () => ({
|
||||||
start: props.start
|
start: props.start
|
||||||
@@ -94,7 +91,7 @@ function setLastWeek() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast14Days() {
|
function setLast14Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format());
|
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
@@ -112,14 +109,14 @@ function setLastMonth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast30Days() {
|
function setLast30Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format());
|
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLast90Days() {
|
function setLast90Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
|
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getDayJsInstance()().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,38 +140,6 @@ function setLastYear() {
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
const displayLabel = computed(() => {
|
|
||||||
if (!startDay.value) return 'Pick a date';
|
|
||||||
if (!endDay.value) {
|
|
||||||
return getDateRangeLabel(startDay.value, startDay.value, {
|
|
||||||
dateFormat: organization?.value?.date_format,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return getDateRangeLabel(startDay.value, endDay.value, {
|
|
||||||
dateFormat: organization?.value?.date_format,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextRange = computed(() => {
|
|
||||||
if (!startDay.value || !endDay.value) return undefined;
|
|
||||||
return shiftDateRange(startDay.value, endDay.value, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextDisabled = computed(
|
|
||||||
() =>
|
|
||||||
!props.allowFuture &&
|
|
||||||
!!nextRange.value &&
|
|
||||||
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
|
|
||||||
);
|
|
||||||
|
|
||||||
function navigate(direction: DateRangeDirection) {
|
|
||||||
if (!startDay.value || !endDay.value) return;
|
|
||||||
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
|
|
||||||
emit('update:start', shifted.start.format());
|
|
||||||
emit('update:end', shifted.end.format());
|
|
||||||
emit('submit');
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(open, (value) => {
|
watch(open, (value) => {
|
||||||
if (value === false) {
|
if (value === false) {
|
||||||
emit('submit');
|
emit('submit');
|
||||||
@@ -183,120 +148,77 @@ watch(open, (value) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DateRangeNavigator
|
<Popover v-model:open="open">
|
||||||
:label="displayLabel"
|
<PopoverTrigger as-child>
|
||||||
:next-disabled="nextDisabled"
|
<Button
|
||||||
previous-test-id="date_range_picker_previous"
|
variant="outline"
|
||||||
trigger-test-id="date_range_picker_display"
|
:class="
|
||||||
next-test-id="date_range_picker_next"
|
twMerge(
|
||||||
@previous="navigate(-1)"
|
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
|
||||||
@next="navigate(1)">
|
!modelValue && 'text-muted-foreground'
|
||||||
<template #trigger="{ triggerClass }">
|
)
|
||||||
<Popover v-model:open="open">
|
">
|
||||||
<PopoverTrigger as-child>
|
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
|
||||||
<Button
|
<template v-if="modelValue.start">
|
||||||
type="button"
|
<template v-if="modelValue.end">
|
||||||
variant="ghost"
|
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||||
size="sm"
|
-
|
||||||
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
|
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
|
||||||
data-testid="date_range_picker_display">
|
</template>
|
||||||
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
|
<template v-else>
|
||||||
<span class="min-w-0 truncate">{{ displayLabel }}</span>
|
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||||
</Button>
|
</template>
|
||||||
</PopoverTrigger>
|
</template>
|
||||||
<PopoverContent class="w-auto p-0">
|
<template v-else> Pick a date </template>
|
||||||
<div class="flex divide-x divide-border-secondary">
|
</Button>
|
||||||
<div
|
</PopoverTrigger>
|
||||||
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
<PopoverContent class="w-auto p-0">
|
||||||
<Button
|
<div class="flex divide-x divide-border-secondary">
|
||||||
variant="ghost"
|
<div
|
||||||
size="sm"
|
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
||||||
class="justify-start"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
|
||||||
@click="setToday"
|
>Today</Button
|
||||||
>Today</Button
|
>
|
||||||
>
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
|
||||||
<Button
|
>This Week</Button
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
|
||||||
class="justify-start"
|
>Last Week</Button
|
||||||
@click="setThisWeek"
|
>
|
||||||
>This Week</Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
|
||||||
>
|
>Last 14 days</Button
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
|
||||||
size="sm"
|
>This Month</Button
|
||||||
class="justify-start"
|
>
|
||||||
@click="setLastWeek"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
|
||||||
>Last Week</Button
|
>Last Month</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
|
||||||
variant="ghost"
|
>Last 30 days</Button
|
||||||
size="sm"
|
>
|
||||||
class="justify-start"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
|
||||||
@click="setLast14Days"
|
>Last 90 days</Button
|
||||||
>Last 14 Days</Button
|
>
|
||||||
>
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
|
||||||
<Button
|
>Last 12 months</Button
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
|
||||||
class="justify-start"
|
>This year</Button
|
||||||
@click="setThisMonth"
|
>
|
||||||
>This Month</Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
|
||||||
>
|
>Last year</Button
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
</div>
|
||||||
size="sm"
|
<div class="pl-2">
|
||||||
class="justify-start"
|
<RangeCalendar
|
||||||
@click="setLastMonth"
|
v-model="modelValue"
|
||||||
>Last Month</Button
|
initial-focus
|
||||||
>
|
:number-of-months="2"
|
||||||
<Button
|
:max-value="today"
|
||||||
variant="ghost"
|
:week-starts-on="weekStartsOn" />
|
||||||
size="sm"
|
</div>
|
||||||
class="justify-start"
|
</div>
|
||||||
@click="setLast30Days"
|
</PopoverContent>
|
||||||
>Last 30 Days</Button
|
</Popover>
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLast90Days"
|
|
||||||
>Last 90 Days</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLast12Months"
|
|
||||||
>Last 12 Months</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setThisYear"
|
|
||||||
>This Year</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLastYear"
|
|
||||||
>Last Year</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2">
|
|
||||||
<RangeCalendar
|
|
||||||
v-model="modelValue"
|
|
||||||
initial-focus
|
|
||||||
:number-of-months="2"
|
|
||||||
:max-value="allowFuture ? undefined : today"
|
|
||||||
:week-starts-on="weekStartsOn" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</template>
|
|
||||||
</DateRangeNavigator>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
|||||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||||
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
|
|
||||||
import { Label } from './label/index';
|
import { Label } from './label/index';
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
@@ -212,7 +211,6 @@ export {
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
DateRangePicker,
|
DateRangePicker,
|
||||||
DateRangeNavigator,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import dayjs from 'dayjs';
|
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from './time';
|
|
||||||
import { getDateRangeLabel, shiftDateRange } from './dateRange';
|
|
||||||
|
|
||||||
const reference = dayjs('2026-09-21');
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getDateRangeLabel', () => {
|
|
||||||
test('labels relative weeks', () => {
|
|
||||||
getDayJsInstance();
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('This Week');
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(
|
|
||||||
reference.subtract(1, 'week').startOf('week'),
|
|
||||||
reference.subtract(1, 'week').endOf('week'),
|
|
||||||
{ referenceDate: reference }
|
|
||||||
)
|
|
||||||
).toBe('Last Week');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('labels common rolling ranges', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('Last 14 Days');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('labels full calendar months and years', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('September 2025');
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('2024');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keeps relative labels for the current periods', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('This Month');
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('This Year');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses the organization date format for custom ranges', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
|
|
||||||
dateFormat: 'slash-separated-dd-mm-yyyy',
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('03/08/2026 – 12/08/2026');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders a single historical day without duplicating it', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('Aug 3, 2026');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shiftDateRange', () => {
|
|
||||||
test('moves full weeks as calendar periods', () => {
|
|
||||||
getDayJsInstance();
|
|
||||||
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
|
|
||||||
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
|
|
||||||
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moves full months without truncating longer months', () => {
|
|
||||||
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
|
|
||||||
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
|
|
||||||
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moves custom ranges by their inclusive duration', () => {
|
|
||||||
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
|
|
||||||
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
|
|
||||||
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizes custom range offsets across the spring DST transition', () => {
|
|
||||||
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
|
||||||
const shifted = shiftDateRange(
|
|
||||||
getLocalizedDayJs('2026-03-14T23:00:00Z'),
|
|
||||||
getLocalizedDayJs('2026-03-27T23:00:00Z'),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
|
|
||||||
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizes custom range offsets across the autumn DST transition', () => {
|
|
||||||
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
|
||||||
const shifted = shiftDateRange(
|
|
||||||
getLocalizedDayJs('2026-10-17T22:00:00Z'),
|
|
||||||
getLocalizedDayJs('2026-10-30T23:00:00Z'),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
|
|
||||||
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
|
|
||||||
|
|
||||||
export type DateRangeDirection = -1 | 1;
|
|
||||||
|
|
||||||
function isSameDay(left: Dayjs, right: Dayjs): boolean {
|
|
||||||
return left.isSame(right, 'day');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
|
|
||||||
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCompactRange(start: Dayjs, end: Dayjs): string {
|
|
||||||
if (start.year() !== end.year()) {
|
|
||||||
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
|
|
||||||
}
|
|
||||||
if (start.month() !== end.month()) {
|
|
||||||
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
|
|
||||||
}
|
|
||||||
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a human-friendly label for an inclusive date range. Relative labels
|
|
||||||
* are intentionally shared by reporting, the timesheet, and the calendar.
|
|
||||||
*/
|
|
||||||
export function getDateRangeLabel(
|
|
||||||
start: Dayjs,
|
|
||||||
end: Dayjs,
|
|
||||||
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
|
|
||||||
): string {
|
|
||||||
// Ensure the configured first day of the week is applied before using startOf('week').
|
|
||||||
getDayJsInstance();
|
|
||||||
|
|
||||||
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
|
|
||||||
const rangeStart = start.startOf('day');
|
|
||||||
const rangeEnd = end.startOf('day');
|
|
||||||
|
|
||||||
const relativeDays: Array<[number, string]> = [
|
|
||||||
[0, 'Today'],
|
|
||||||
[-1, 'Yesterday'],
|
|
||||||
[1, 'Tomorrow'],
|
|
||||||
];
|
|
||||||
for (const [offset, label] of relativeDays) {
|
|
||||||
const day = reference.add(offset, 'day');
|
|
||||||
if (isRange(rangeStart, rangeEnd, day, day)) return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
|
|
||||||
['week', 0, 'This Week'],
|
|
||||||
['week', -1, 'Last Week'],
|
|
||||||
['week', 1, 'Next Week'],
|
|
||||||
['month', 0, 'This Month'],
|
|
||||||
['month', -1, 'Last Month'],
|
|
||||||
['month', 1, 'Next Month'],
|
|
||||||
['year', 0, 'This Year'],
|
|
||||||
['year', -1, 'Last Year'],
|
|
||||||
['year', 1, 'Next Year'],
|
|
||||||
];
|
|
||||||
for (const [unit, offset, label] of relativePeriods) {
|
|
||||||
const period = reference.add(offset, unit);
|
|
||||||
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Named calendar periods outside the relative windows above, e.g. "May 2025"
|
|
||||||
// for a full month and "2025" for a full year.
|
|
||||||
const monthStart = rangeStart.startOf('month');
|
|
||||||
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
|
|
||||||
return rangeStart.format('MMMM YYYY');
|
|
||||||
}
|
|
||||||
const yearStart = rangeStart.startOf('year');
|
|
||||||
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
|
|
||||||
return rangeStart.format('YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rollingRanges: Array<[number, string]> = [
|
|
||||||
[14, 'Last 14 Days'],
|
|
||||||
[30, 'Last 30 Days'],
|
|
||||||
[90, 'Last 90 Days'],
|
|
||||||
];
|
|
||||||
for (const [days, label] of rollingRanges) {
|
|
||||||
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
|
|
||||||
return 'Last 12 Months';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSameDay(rangeStart, rangeEnd)) {
|
|
||||||
return options.dateFormat
|
|
||||||
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
|
|
||||||
: rangeStart.format('MMM D, YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.dateFormat) {
|
|
||||||
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
|
|
||||||
rangeEnd.format('YYYY-MM-DD'),
|
|
||||||
options.dateFormat
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
return formatCompactRange(rangeStart, rangeEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shift an inclusive range by one period, preserving full calendar periods. */
|
|
||||||
export function shiftDateRange(
|
|
||||||
start: Dayjs,
|
|
||||||
end: Dayjs,
|
|
||||||
direction: DateRangeDirection
|
|
||||||
): { start: Dayjs; end: Dayjs } {
|
|
||||||
getDayJsInstance();
|
|
||||||
|
|
||||||
const rangeStart = start.startOf('day');
|
|
||||||
const rangeEnd = end.endOf('day');
|
|
||||||
|
|
||||||
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
|
|
||||||
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
|
|
||||||
return { start: shiftedStart, end: shiftedStart.endOf('year') };
|
|
||||||
}
|
|
||||||
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
|
|
||||||
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
|
|
||||||
return { start: shiftedStart, end: shiftedStart.endOf('month') };
|
|
||||||
}
|
|
||||||
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
|
|
||||||
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
|
|
||||||
return { start: shiftedStart, end: shiftedStart.endOf('week') };
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
|
|
||||||
return {
|
|
||||||
start: rangeStart.add(days * direction, 'day').startOf('day'),
|
|
||||||
end: rangeEnd.add(days * direction, 'day').endOf('day'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
@@ -57,6 +57,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
||||||
deep: true,
|
deep: true,
|
||||||
});
|
});
|
||||||
@@ -88,12 +89,23 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
try {
|
try {
|
||||||
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
||||||
if (timeEntriesResponse?.data) {
|
if (timeEntriesResponse?.data) {
|
||||||
currentTimeEntry.value = timeEntriesResponse.data;
|
if (timeEntriesResponse.data) {
|
||||||
} else if (currentTimeEntry.value.id !== '') {
|
currentTimeEntry.value = timeEntriesResponse.data;
|
||||||
// No active time entry on server
|
if (
|
||||||
// Only reset if we had a previously started timer (has an ID)
|
currentTimeEntry.value.start !== '' &&
|
||||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
currentTimeEntry.value.end === null
|
||||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
) {
|
||||||
|
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 {
|
} catch {
|
||||||
// API error (e.g., 404 when no active time entry)
|
// 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)
|
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||||
if (currentTimeEntry.value.id !== '') {
|
if (currentTimeEntry.value.id !== '') {
|
||||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||||
|
stopLiveTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -281,18 +294,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
return isActive.value && currentTimeEntry.value.type === 'break';
|
return isActive.value && currentTimeEntry.value.type === 'break';
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
isActive,
|
|
||||||
(active) => {
|
|
||||||
if (active) {
|
|
||||||
startLiveTimer();
|
|
||||||
} else {
|
|
||||||
stopLiveTimer();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
async function setActiveState(newState: boolean) {
|
async function setActiveState(newState: boolean) {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class PasswordConfirmationTest extends TestCase
|
class PasswordConfirmationTest extends TestCase
|
||||||
@@ -44,43 +43,4 @@ class PasswordConfirmationTest extends TestCase
|
|||||||
|
|
||||||
$response->assertSessionHasErrors();
|
$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'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace Tests\Feature;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -94,62 +93,4 @@ class PasswordResetTest extends TestCase
|
|||||||
return true;
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
public function test_import_return_error_message_if_import_fails(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -105,9 +105,6 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
|||||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||||
'id' => $invitation->getKey(),
|
'id' => $invitation->getKey(),
|
||||||
]);
|
]);
|
||||||
// Joining sets the organization as the current one for the user, independently of the
|
|
||||||
// placeholders that were merged into them
|
|
||||||
$this->assertSame($user->organization->getKey(), $user2->user->fresh()->current_team_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void
|
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void
|
||||||
|
|||||||
@@ -53,23 +53,6 @@ class UserModelTest extends ModelTestAbstract
|
|||||||
$this->assertTrue($canAccess);
|
$this->assertTrue($canAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_placeholder_user_with_a_super_admin_email_can_not_access_admin_panel(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
Config::set('auth.super_admins', ['some@email.test', 'other@email.test']);
|
|
||||||
$user = User::factory()->placeholder()->create([
|
|
||||||
'email' => 'some@email.test',
|
|
||||||
]);
|
|
||||||
$panelProvider = new AdminPanelProvider(app());
|
|
||||||
$mainPanel = $panelProvider->panel(Panel::make());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$canAccess = $user->canAccessPanel($mainPanel);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertFalse($canAccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
|
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -412,11 +412,10 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertDatabaseHas(Organization::class, [
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
'id' => $organizationOfA->getKey(),
|
'id' => $organizationOfA->getKey(),
|
||||||
]);
|
]);
|
||||||
// The placeholder user should exist and must not reference the deleted organization,
|
// The placeholder user should exist with current_team_id set to the org where they are a placeholder
|
||||||
// which is what caused the foreign key violation in #989
|
|
||||||
$placeholderUser = User::query()->where('is_placeholder', true)->first();
|
$placeholderUser = User::query()->where('is_placeholder', true)->first();
|
||||||
$this->assertNotNull($placeholderUser);
|
$this->assertNotNull($placeholderUser);
|
||||||
$this->assertNull($placeholderUser->current_team_id);
|
$this->assertSame($organizationOfA->getKey(), $placeholderUser->current_team_id);
|
||||||
$this->assertDatabaseHas(Member::class, [
|
$this->assertDatabaseHas(Member::class, [
|
||||||
'id' => $memberBInOrgA->getKey(),
|
'id' => $memberBInOrgA->getKey(),
|
||||||
'user_id' => $placeholderUser->getKey(),
|
'user_id' => $placeholderUser->getKey(),
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class ImportServiceTest extends TestCase
|
|||||||
$this->assertSame(1, $report->usersCreated);
|
$this->assertSame(1, $report->usersCreated);
|
||||||
$this->assertSame(2, $report->projectsCreated);
|
$this->assertSame(2, $report->projectsCreated);
|
||||||
$this->assertSame(1, $report->clientsCreated);
|
$this->assertSame(1, $report->clientsCreated);
|
||||||
Storage::disk(config('filesystems.default'))->assertDirectoryEmpty('import');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void
|
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void
|
||||||
|
|||||||
@@ -42,31 +42,6 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
|||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_throws_exception_if_zip_exceeds_uncompressed_size_limit(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config(['import.zip_max_uncompressed_size' => 10]);
|
|
||||||
$zipPath = $this->createTestZip('solidtime_import_test_1');
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$importer = new SolidtimeImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = file_get_contents($zipPath);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertInstanceOf(ImportException::class, $e);
|
|
||||||
$this->assertSame('ZIP uncompressed size exceeds the maximum of 10 bytes', $e->getMessage());
|
|
||||||
$this->assertSame(0, $importer->getReport()->timeEntriesCreated);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -39,31 +39,6 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
|||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_throws_exception_if_zip_contains_too_many_files(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config(['import.zip_max_files' => 1]);
|
|
||||||
$zipPath = $this->createTestZip('toggl_data_import_test_1');
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$importer = new TogglDataImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = file_get_contents($zipPath);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertInstanceOf(ImportException::class, $e);
|
|
||||||
$this->assertSame('ZIP contains too many files, maximum is 1', $e->getMessage());
|
|
||||||
$this->assertSame(0, $importer->getReport()->projectsCreated);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Service\Import\Importers\ImportException;
|
|
||||||
use App\Service\Import\Importers\ZipImportHelper;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
|
||||||
use Tests\TestCase;
|
|
||||||
use ZipArchive;
|
|
||||||
|
|
||||||
#[CoversClass(ZipImportHelper::class)]
|
|
||||||
class ZipImportHelperTest extends TestCase
|
|
||||||
{
|
|
||||||
private TemporaryDirectory $sourceDirectory;
|
|
||||||
|
|
||||||
private TemporaryDirectory $targetDirectory;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
$this->sourceDirectory = TemporaryDirectory::make();
|
|
||||||
$this->targetDirectory = TemporaryDirectory::make();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
$this->sourceDirectory->delete();
|
|
||||||
$this->targetDirectory->delete();
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $files
|
|
||||||
*/
|
|
||||||
private function createZip(array $files): string
|
|
||||||
{
|
|
||||||
$zipPath = $this->sourceDirectory->path('test.zip');
|
|
||||||
$zip = new ZipArchive;
|
|
||||||
$zip->open($zipPath, ZipArchive::CREATE);
|
|
||||||
foreach ($files as $name => $content) {
|
|
||||||
$zip->addFromString($name, $content);
|
|
||||||
}
|
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
return $zipPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertNothingExtracted(): void
|
|
||||||
{
|
|
||||||
$this->assertSame([], array_values(array_diff(scandir($this->targetDirectory->path()), ['.', '..'])));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_extracts_files_and_nested_directories(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'meta.json' => '{"version":"1.0"}',
|
|
||||||
'nested/dir/file.csv' => 'a,b',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('{"version":"1.0"}', file_get_contents($this->targetDirectory->path('meta.json')));
|
|
||||||
$this->assertSame('a,b', file_get_contents($this->targetDirectory->path('nested/dir/file.csv')));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_if_file_is_not_a_zip(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$path = $this->sourceDirectory->path('not-a-zip.txt');
|
|
||||||
file_put_contents($path, 'not a zip');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($path, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('Invalid ZIP, error code: 19', $e->getMessage());
|
|
||||||
$this->assertNothingExtracted();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_if_zip_contains_too_many_files(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config(['import.zip_max_files' => 2]);
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'a.txt' => 'a',
|
|
||||||
'b.txt' => 'b',
|
|
||||||
'c.txt' => 'c',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('ZIP contains too many files, maximum is 2', $e->getMessage());
|
|
||||||
$this->assertNothingExtracted();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_before_writing_if_declared_uncompressed_size_exceeds_limit(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config(['import.zip_max_uncompressed_size' => 100]);
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'a.txt' => str_repeat('a', 60),
|
|
||||||
'b.txt' => str_repeat('b', 60),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('ZIP uncompressed size exceeds the maximum of 100 bytes', $e->getMessage());
|
|
||||||
$this->assertNothingExtracted();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_if_actual_uncompressed_size_exceeds_limit_despite_forged_headers(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config(['import.zip_max_uncompressed_size' => 1000]);
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'bomb.bin' => str_repeat("\0", 100000),
|
|
||||||
]);
|
|
||||||
// Forge the uncompressed size in the local file header (offset 22) and central directory header (offset 24)
|
|
||||||
$content = file_get_contents($zipPath);
|
|
||||||
$forgedSize = pack('V', 10);
|
|
||||||
$localHeaderOffset = strpos($content, "PK\x03\x04");
|
|
||||||
$centralHeaderOffset = strpos($content, "PK\x01\x02");
|
|
||||||
$this->assertNotFalse($localHeaderOffset);
|
|
||||||
$this->assertNotFalse($centralHeaderOffset);
|
|
||||||
$content = substr_replace($content, $forgedSize, $localHeaderOffset + 22, 4);
|
|
||||||
$content = substr_replace($content, $forgedSize, $centralHeaderOffset + 24, 4);
|
|
||||||
file_put_contents($zipPath, $content);
|
|
||||||
$zip = new ZipArchive;
|
|
||||||
$this->assertTrue($zip->open($zipPath, ZipArchive::RDONLY));
|
|
||||||
$this->assertSame(10, $zip->statIndex(0)['size']);
|
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('ZIP uncompressed size exceeds the maximum of 1000 bytes', $e->getMessage());
|
|
||||||
$extracted = $this->targetDirectory->path('bomb.bin');
|
|
||||||
if (file_exists($extracted)) {
|
|
||||||
$this->assertLessThanOrEqual(1000, filesize($extracted));
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_if_zip_contains_path_traversal(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'../evil.txt' => 'evil',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('ZIP contains an invalid file path: "../evil.txt"', $e->getMessage());
|
|
||||||
$this->assertFileDoesNotExist(dirname($this->targetDirectory->path()).'/evil.txt');
|
|
||||||
$this->assertNothingExtracted();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_if_zip_contains_nested_path_traversal(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'sub/../../evil.txt' => 'evil',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('ZIP contains an invalid file path: "sub/../../evil.txt"', $e->getMessage());
|
|
||||||
$this->assertNothingExtracted();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_if_zip_contains_absolute_path(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'/tmp/evil.txt' => 'evil',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('ZIP contains an invalid file path: "/tmp/evil.txt"', $e->getMessage());
|
|
||||||
$this->assertNothingExtracted();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extract_throws_exception_if_zip_contains_backslash_path(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$zipPath = $this->createZip([
|
|
||||||
'..\\evil.txt' => 'evil',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert
|
|
||||||
$this->assertSame('ZIP contains an invalid file path: "..\\evil.txt"', $e->getMessage());
|
|
||||||
$this->assertNothingExtracted();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->fail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ use App\Models\TimeEntry;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\MemberService;
|
use App\Service\MemberService;
|
||||||
use App\Service\UserService;
|
use App\Service\UserService;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Tests\TestCaseWithDatabase;
|
use Tests\TestCaseWithDatabase;
|
||||||
@@ -65,48 +64,6 @@ class MemberServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame(Role::Admin->value, $oldOwnerMember->refresh()->role);
|
$this->assertSame(Role::Admin->value, $oldOwnerMember->refresh()->role);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_make_member_to_placeholder_does_not_copy_the_credentials_and_account_state_of_the_user(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'password' => Hash::make('secret-password'),
|
|
||||||
'remember_token' => 'remember-me-token',
|
|
||||||
'two_factor_secret' => 'two-factor-secret',
|
|
||||||
'two_factor_recovery_codes' => 'two-factor-recovery-codes',
|
|
||||||
'two_factor_confirmed_at' => '2026-09-16 10:00:00',
|
|
||||||
'email_verified_at' => '2026-09-16 09:00:00',
|
|
||||||
'pending_email' => 'pending@example.com',
|
|
||||||
'profile_photo_path' => 'profile-photos/photo.png',
|
|
||||||
]);
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$this->memberService->makeMemberToPlaceholder($member);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$member->refresh();
|
|
||||||
$placeholderUser = $member->user;
|
|
||||||
$this->assertTrue($placeholderUser->is_placeholder);
|
|
||||||
$this->assertSame($user->email, $placeholderUser->email);
|
|
||||||
$this->assertNull($placeholderUser->password);
|
|
||||||
$this->assertNull($placeholderUser->remember_token);
|
|
||||||
$this->assertNull($placeholderUser->two_factor_secret);
|
|
||||||
$this->assertNull($placeholderUser->two_factor_recovery_codes);
|
|
||||||
$this->assertNull($placeholderUser->two_factor_confirmed_at);
|
|
||||||
$this->assertNull($placeholderUser->email_verified_at);
|
|
||||||
$this->assertNull($placeholderUser->pending_email);
|
|
||||||
$this->assertNull($placeholderUser->current_team_id);
|
|
||||||
$this->assertNull($placeholderUser->profile_photo_path);
|
|
||||||
// the user the placeholder was created from keeps their own credentials and state
|
|
||||||
$user->refresh();
|
|
||||||
$this->assertTrue(Hash::check('secret-password', (string) $user->password));
|
|
||||||
$this->assertSame('two-factor-secret', $user->two_factor_secret);
|
|
||||||
$this->assertNotNull($user->email_verified_at);
|
|
||||||
$this->assertSame('pending@example.com', $user->pending_email);
|
|
||||||
$this->assertSame('profile-photos/photo.png', $user->profile_photo_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void
|
public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user