mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 13:53:17 +01:00
Compare commits
9 Commits
feature/fi
...
feature/bi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e793b81b35 | ||
|
|
d54296e66a | ||
|
|
95ddbf9ead | ||
|
|
70646a0dd4 | ||
|
|
5b12c09747 | ||
|
|
24023353f2 | ||
|
|
720d20c10e | ||
|
|
82ea9af8b5 | ||
|
|
169d522da0 |
37
app/Auth/ActiveUserProvider.php
Normal file
37
app/Auth/ActiveUserProvider.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
@@ -159,7 +158,7 @@ class ChartController extends Controller
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
||||
if (! $showBillableRate) {
|
||||
throw new AuthorizationException('You do not have permission to view billable rates.');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\PermissionStore;
|
||||
@@ -49,4 +51,30 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
||||
}
|
||||
|
||||
protected function canUseBillableRates(Organization $organization): bool
|
||||
{
|
||||
return app(BillingContract::class)->canUseBillableRates($organization);
|
||||
}
|
||||
|
||||
protected function canSeeBillableRates(Organization $organization): bool
|
||||
{
|
||||
if (! $this->canUseBillableRates($organization)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizations that can not use billable rates may keep or remove an existing rate, but not set a new one
|
||||
*
|
||||
* @throws FeatureIsNotAvailableInFreePlanApiException
|
||||
*/
|
||||
protected function checkBillableRateChange(Organization $organization, ?int $oldBillableRate, ?int $newBillableRate): void
|
||||
{
|
||||
if ($newBillableRate !== null && $newBillableRate !== $oldBillableRate && ! $this->canUseBillableRates($organization)) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ class MemberController extends Controller
|
||||
$this->checkPermission($organization, 'members:update', $member);
|
||||
|
||||
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
|
||||
$this->checkBillableRateChange($organization, $member->billable_rate, $request->getBillableRate());
|
||||
$member->billable_rate = $request->getBillableRate();
|
||||
|
||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
@@ -32,7 +32,7 @@ class OrganizationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:view');
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
||||
|
||||
return new OrganizationResource($organization, $showBillableRate);
|
||||
}
|
||||
@@ -54,7 +54,10 @@ class OrganizationController extends Controller
|
||||
if ($request->getCurrency() !== null) {
|
||||
$organization->currency = $request->getCurrency();
|
||||
}
|
||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||
if ($request->getEmployeesCanSeeBillableRates() !== null && $request->getEmployeesCanSeeBillableRates() !== $organization->employees_can_see_billable_rates) {
|
||||
if (! $this->canUseBillableRates($organization)) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
}
|
||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||
}
|
||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||
@@ -83,6 +86,7 @@ class OrganizationController extends Controller
|
||||
}
|
||||
$hasBillableRate = $request->has('billable_rate');
|
||||
if ($hasBillableRate) {
|
||||
$this->checkBillableRateChange($organization, $organization->billable_rate, $request->getBillableRate());
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
$organization->billable_rate = $request->getBillableRate();
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class ProjectController extends Controller
|
||||
->orderBy('id')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
||||
|
||||
return new ProjectCollection($projects, $showBillableRate);
|
||||
}
|
||||
@@ -99,6 +99,7 @@ class ProjectController extends Controller
|
||||
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:create');
|
||||
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
|
||||
$project = new Project;
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
@@ -125,6 +126,7 @@ class ProjectController extends Controller
|
||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:update', $project);
|
||||
$this->checkBillableRateChange($organization, $project->billable_rate, $request->getBillableRate());
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
@@ -139,14 +141,17 @@ class ProjectController extends Controller
|
||||
}
|
||||
$oldBillableRate = $project->billable_rate;
|
||||
$clientIdChanged = false;
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
// Organizations that can not use billable rates do not receive the rate of the project, so the incoming value is ignored to keep the existing rate
|
||||
if ($this->canUseBillableRates($organization)) {
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
}
|
||||
if ($project->client_id !== $request->input('client_id')) {
|
||||
$project->client_id = $request->input('client_id');
|
||||
$clientIdChanged = true;
|
||||
}
|
||||
$project->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
if ($oldBillableRate !== $project->billable_rate) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
}
|
||||
if ($clientIdChanged) {
|
||||
|
||||
@@ -65,6 +65,7 @@ class ProjectMemberController extends Controller
|
||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:create', $project);
|
||||
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
|
||||
|
||||
$member = Member::findOrFail((string) $request->input('member_id'));
|
||||
if ($member->user->is_placeholder) {
|
||||
@@ -98,6 +99,7 @@ class ProjectMemberController extends Controller
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||
$this->checkBillableRateChange($organization, $projectMember->billable_rate, $request->getBillableRate());
|
||||
$oldBillableRate = $projectMember->billable_rate;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$projectMember->save();
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||
@@ -240,7 +239,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$user = $this->user();
|
||||
$timezone = $user->timezone;
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -386,7 +385,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$user = $this->user();
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
||||
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
@@ -441,7 +440,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$debug = $request->getDebug();
|
||||
$user = $this->user();
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
||||
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
|
||||
@@ -57,6 +57,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||
'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization),
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
|
||||
@@ -24,6 +24,7 @@ class ImportRequest extends BaseFormRequest
|
||||
'data' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:'.config('import.max_data_size'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string|null $pending_email
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string|null $password
|
||||
* @property string|null $remember_token
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string|null $two_factor_recovery_codes
|
||||
* @property Carbon|null $two_factor_confirmed_at
|
||||
* @property string $timezone
|
||||
* @property bool $is_placeholder
|
||||
* @property Weekday $week_start
|
||||
@@ -150,7 +153,9 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
||||
return $this->is_placeholder === false
|
||||
&& in_array($this->email, config('auth.super_admins', []), true)
|
||||
&& $this->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
public function isMemberOfOrganization(Organization $organization): bool
|
||||
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Auth\ActiveUserProvider;
|
||||
use App\Models\Passport\AuthCode;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\RefreshToken;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
@@ -26,6 +29,13 @@ class AuthServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Replaces the built-in eloquent user provider, so that no authentication flow can
|
||||
// resolve a placeholder user. The driver name is kept, because Passport recognizes
|
||||
// only providers that are configured with the driver "eloquent".
|
||||
Auth::provider('eloquent', function (Application $app, array $config): ActiveUserProvider {
|
||||
return new ActiveUserProvider($app->make('hash'), $config['model']);
|
||||
});
|
||||
|
||||
// define scopes for passport tokens
|
||||
Passport::tokensCan([
|
||||
'create' => 'Create resources',
|
||||
|
||||
@@ -54,4 +54,13 @@ class BillingContract
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the organization can set and see billable rates
|
||||
* Without a billing system every organization can use billable rates
|
||||
*/
|
||||
public function canUseBillableRates(Organization $organization): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,8 @@ use App\Service\Import\Importers\ImporterContract;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportService
|
||||
{
|
||||
@@ -25,8 +22,6 @@ class ImportService
|
||||
/** @var ImporterContract $importer */
|
||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||
$importer->init($organization);
|
||||
Storage::disk(config('filesystems.default'))
|
||||
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
|
||||
|
||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\ColorService;
|
||||
use App\Service\Import\ImportDatabaseHelper;
|
||||
use App\Service\TimezoneService;
|
||||
@@ -71,9 +72,15 @@ abstract class DefaultImporter implements ImporterContract
|
||||
|
||||
protected BillableRateService $billableRateService;
|
||||
|
||||
/**
|
||||
* Organizations that can not use billable rates import projects, members and project members without billable rates
|
||||
*/
|
||||
protected bool $canUseBillableRates;
|
||||
|
||||
public function init(Organization $organization): void
|
||||
{
|
||||
$this->organization = $organization;
|
||||
$this->canUseBillableRates = app(BillingContract::class)->canUseBillableRates($organization);
|
||||
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
@@ -96,7 +103,11 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'string',
|
||||
'in:placeholder',
|
||||
],
|
||||
]);
|
||||
], beforeSave: function (Member $member): void {
|
||||
if (! $this->canUseBillableRates) {
|
||||
$member->billable_rate = null;
|
||||
}
|
||||
});
|
||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
@@ -120,7 +131,7 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'uuid',
|
||||
],
|
||||
], beforeSave: function (Project $project): void {
|
||||
if ($project->billable_rate === 0) {
|
||||
if ($project->billable_rate === 0 || ! $this->canUseBillableRates) {
|
||||
$project->billable_rate = null;
|
||||
}
|
||||
});
|
||||
@@ -134,7 +145,7 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'max:2147483647',
|
||||
],
|
||||
], beforeSave: function (ProjectMember $projectMember): void {
|
||||
if ($projectMember->billable_rate === 0) {
|
||||
if ($projectMember->billable_rate === 0 || ! $this->canUseBillableRates) {
|
||||
$projectMember->billable_rate = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ use Illuminate\Support\Str;
|
||||
use League\Csv\Reader;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class SolidtimeImporter extends DefaultImporter
|
||||
{
|
||||
@@ -34,16 +33,10 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$temporaryDirectoryZip = null;
|
||||
$temporaryDirectory = null;
|
||||
try {
|
||||
$zip = new ZipArchive;
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||
if ($res !== true) {
|
||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||
}
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
$zip->extractTo($temporaryDirectory->path());
|
||||
$zip->close();
|
||||
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
||||
throw new ImportException('File "meta.json" missing in ZIP');
|
||||
|
||||
@@ -13,7 +13,6 @@ use Illuminate\Support\Str;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ValueError;
|
||||
use ZipArchive;
|
||||
|
||||
class TogglDataImporter extends DefaultImporter
|
||||
{
|
||||
@@ -26,16 +25,10 @@ class TogglDataImporter extends DefaultImporter
|
||||
$temporaryDirectoryZip = null;
|
||||
$temporaryDirectory = null;
|
||||
try {
|
||||
$zip = new ZipArchive;
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||
if ($res !== true) {
|
||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||
}
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
$zip->extractTo($temporaryDirectory->path());
|
||||
$zip->close();
|
||||
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
||||
if (! file_exists($temporaryDirectory->path('clients.json'))) {
|
||||
throw new ImportException('File "clients.json" missing in ZIP');
|
||||
}
|
||||
|
||||
129
app/Service/Import/Importers/ZipImportHelper.php
Normal file
129
app/Service/Import/Importers/ZipImportHelper.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?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,7 +218,16 @@ class MemberService
|
||||
|
||||
$placeholderUser = $user->replicate();
|
||||
$placeholderUser->is_placeholder = true;
|
||||
$placeholderUser->current_team_id = $member->organization_id;
|
||||
// Reset authentication relevant properties on the placeholder user
|
||||
$placeholderUser->password = null;
|
||||
$placeholderUser->remember_token = null;
|
||||
$placeholderUser->two_factor_secret = null;
|
||||
$placeholderUser->two_factor_recovery_codes = null;
|
||||
$placeholderUser->two_factor_confirmed_at = null;
|
||||
$placeholderUser->email_verified_at = null;
|
||||
$placeholderUser->pending_email = null;
|
||||
$placeholderUser->current_team_id = null;
|
||||
$placeholderUser->profile_photo_path = null;
|
||||
$placeholderUser->save();
|
||||
|
||||
$member->user()->associate($placeholderUser);
|
||||
|
||||
34
config/import.php
Normal file
34
config/import.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?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),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -1579,57 +1579,6 @@ test('test that project selection works in create modal', async ({ page, ctx })
|
||||
expect(createBody.data.project_id).not.toBeNull();
|
||||
});
|
||||
|
||||
test('test that ctrl+enter submits the create modal after selecting a project via keyboard', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Regression test for https://github.com/solidtime-io/solidtime/issues/1238
|
||||
const projectName = 'Keyboard Submit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: projectName });
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
// The menu that opened the modal animates out and only then hands focus back to its
|
||||
// own trigger.
|
||||
await expect(page.locator('[role="menu"]')).toHaveCount(0);
|
||||
|
||||
// Fill the description, then move to the project dropdown and select a project purely via keyboard
|
||||
const description = page.getByRole('dialog').getByRole('textbox', { name: 'Description' });
|
||||
await description.fill('Keyboard submit test');
|
||||
await description.press('Tab');
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('button', { name: 'No Project' })
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByTestId('client_dropdown_search').fill(projectName);
|
||||
await expect(page.getByRole('option', { name: projectName })).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
const projectTrigger = page.getByRole('dialog').getByRole('button', { name: projectName });
|
||||
await expect(projectTrigger).toBeVisible();
|
||||
// The trigger label updates on the next tick, but the dropdown keeps focus until its
|
||||
// exit animation has finished and reka-ui hands focus back to the trigger.
|
||||
await expect(projectTrigger).toBeFocused();
|
||||
|
||||
// Ctrl+Enter must submit even though focus is no longer on the description input
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.keyboard.press('Control+Enter'),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.data.description).toBe('Keyboard submit test');
|
||||
expect(createBody.data.project_id).not.toBeNull();
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
const newTimeEntry = page.locator('[data-testid="time_entry_row"]').first();
|
||||
await expect(newTimeEntry.getByTestId('time_entry_description').first()).toHaveValue(
|
||||
'Keyboard submit test'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that tag selection works in create modal', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "v0.0.5"
|
||||
"ref": "v0.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -8564,7 +8564,7 @@
|
||||
},
|
||||
"resources/js/packages/ui": {
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.22",
|
||||
"version": "0.0.23",
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@types/chroma-js": "^3.1.2",
|
||||
|
||||
@@ -28,7 +28,7 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Client </span>
|
||||
|
||||
@@ -30,7 +30,7 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Update Client </span>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { type Client } from '@/packages/api/src';
|
||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||
import Pagination from '@/Components/Common/Pagination.vue';
|
||||
import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||
import { canCreateClients } from '@/utils/permissions';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
|
||||
const { updateMember } = useMembersStore();
|
||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||
@@ -71,6 +72,8 @@ function saveWithChecks() {
|
||||
}
|
||||
}
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
||||
|
||||
onMounted(() => {
|
||||
@@ -136,7 +139,7 @@ const roleDescription = computed(() => {
|
||||
v-model:show="showOwnershipTransferConfirmModal"
|
||||
:member-name="member.name"
|
||||
@submit="submit"></MemberOwnershipTransferConfirmModal>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="saveWithChecks()">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Update Member </span>
|
||||
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
|
||||
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
||||
>Billable Rate</FieldLabel
|
||||
>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<BillableRateInput
|
||||
v-if="billableRatesLocked"
|
||||
v-model="displayedRate"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
name="memberBillableRate" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Select v-model="billableRateSelect">
|
||||
<SelectTrigger id="billableRateType">
|
||||
<SelectValue />
|
||||
|
||||
@@ -78,7 +78,7 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Invite Member </span>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
||||
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
||||
@@ -12,6 +14,8 @@ const props = defineProps<{
|
||||
defineEmits<{
|
||||
sort: [column: SortColumn];
|
||||
}>();
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,7 +37,10 @@ defineEmits<{
|
||||
column="billable_rate"
|
||||
v-bind="props"
|
||||
@sort="$emit('sort', $event)">
|
||||
Billable Rate
|
||||
<span class="inline-flex items-center gap-2 whitespace-nowrap">
|
||||
Billable Rate
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
</span>
|
||||
</SortableTableHeaderCell>
|
||||
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
|
||||
Status
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import type { Member, Organization } from '@/packages/api/src';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
||||
@@ -25,6 +26,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
||||
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
|
||||
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
|
||||
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||
import { formatCents } from '../../../packages/ui/src/utils/money';
|
||||
import {
|
||||
@@ -71,6 +73,8 @@ async function invitePlaceholder(id: string) {
|
||||
const userHasValidMailAddress = computed(() => {
|
||||
return !props.member.email.endsWith('@solidtime-import.test');
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -90,7 +94,8 @@ const userHasValidMailAddress = computed(() => {
|
||||
{{ capitalizeFirstLetter(member.role) }}
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||
<span v-if="member.billable_rate">
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
<span v-else-if="member.billable_rate">
|
||||
{{
|
||||
formatCents(
|
||||
member.billable_rate,
|
||||
|
||||
@@ -23,8 +23,10 @@ import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibility
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
|
||||
const { updateProject } = useProjectsStore();
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
const { clients } = useClientsQuery();
|
||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||
const show = defineModel('show', { default: false });
|
||||
@@ -48,6 +50,11 @@ const project = ref<CreateProjectBody>({
|
||||
is_public: props.originalProject.is_public,
|
||||
});
|
||||
|
||||
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
|
||||
function getUpdateBody() {
|
||||
const { billable_rate: billableRate, ...body } = project.value;
|
||||
return billableRatesLocked.value ? body : { ...body, billable_rate: billableRate };
|
||||
}
|
||||
async function submit() {
|
||||
if (props.originalProject.billable_rate !== project.value.billable_rate) {
|
||||
// make sure that the alert modal is not immediately submitted when user presses enter
|
||||
@@ -56,7 +63,7 @@ async function submit() {
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
await updateProject(props.originalProject.id, project.value);
|
||||
await updateProject(props.originalProject.id, getUpdateBody());
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
@@ -72,14 +79,14 @@ const currentClientName = computed(() => {
|
||||
});
|
||||
|
||||
async function submitBillableRate() {
|
||||
await updateProject(props.originalProject.id, project.value);
|
||||
await updateProject(props.originalProject.id, getUpdateBody());
|
||||
show.value = false;
|
||||
showBillableRateModal.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Edit Project {{ props.originalProject.name }} </span>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed, ref, watch } from 'vue';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||
import Pagination from '@/Components/Common/Pagination.vue';
|
||||
import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
|
||||
export type SortColumn =
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { computed } from 'vue';
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
||||
@@ -25,6 +27,8 @@ const sortState = computed(() => ({
|
||||
function handleSort(column: SortColumn) {
|
||||
emit('sort', column);
|
||||
}
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,7 +54,10 @@ function handleSort(column: SortColumn) {
|
||||
column="billable_rate"
|
||||
v-bind="sortState"
|
||||
@sort="handleSort">
|
||||
Billable Rate
|
||||
<span class="inline-flex items-center gap-2 whitespace-nowrap">
|
||||
Billable Rate
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
</span>
|
||||
</SortableTableHeaderCell>
|
||||
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
|
||||
Status
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||
import type { Project } from '@/packages/api/src';
|
||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||
@@ -15,6 +16,7 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||
@@ -52,8 +54,11 @@ function deleteProject() {
|
||||
}
|
||||
|
||||
function archiveProject() {
|
||||
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
|
||||
const { billable_rate: billableRate, ...project } = props.project;
|
||||
useProjectsStore().updateProject(props.project.id, {
|
||||
...props.project,
|
||||
...project,
|
||||
...(billableRatesLocked.value ? {} : { billable_rate: billableRate }),
|
||||
is_archived: !props.project.is_archived,
|
||||
});
|
||||
}
|
||||
@@ -78,6 +83,8 @@ const billableRateInfo = computed(() => {
|
||||
});
|
||||
|
||||
const showEditProjectModal = ref(false);
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -129,7 +136,8 @@ const showEditProjectModal = ref(false);
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||
<span v-else class="text-text-tertiary">--</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -38,7 +38,7 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span>Add Project Member</span>
|
||||
|
||||
@@ -63,7 +63,7 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span>Edit Project Member</span>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -7,7 +11,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary flex items-center gap-2">
|
||||
Billable Rate
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import type { ProjectMember } from '@/packages/api/src';
|
||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
@@ -10,6 +11,7 @@ import { capitalizeFirstLetter } from '@/utils/format';
|
||||
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
projectMember: ProjectMember;
|
||||
@@ -33,6 +35,8 @@ const member = computed(() => {
|
||||
return members.value.find((member) => member.id === props.projectMember.member_id);
|
||||
});
|
||||
const showEditModal = ref(false);
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,17 +52,20 @@ const showEditModal = ref(false);
|
||||
</span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
||||
{{
|
||||
projectMember.billable_rate
|
||||
? formatCents(
|
||||
projectMember.billable_rate,
|
||||
getOrganizationCurrencyString(),
|
||||
organization?.currency_format,
|
||||
organization?.currency_symbol,
|
||||
organization?.number_format
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
<span v-else>
|
||||
{{
|
||||
projectMember.billable_rate
|
||||
? formatCents(
|
||||
projectMember.billable_rate,
|
||||
getOrganizationCurrencyString(),
|
||||
organization?.currency_format,
|
||||
organization?.currency_symbol,
|
||||
organization?.number_format
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
||||
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
||||
|
||||
@@ -79,7 +79,7 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Report </span>
|
||||
|
||||
@@ -96,7 +96,7 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Report </span>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
ArrowDownTrayIcon,
|
||||
@@ -290,6 +292,8 @@ const tableData = computed(() => {
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -416,7 +420,12 @@ const tableData = computed(() => {
|
||||
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
|
||||
Duration
|
||||
</div>
|
||||
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="flex items-center justify-end gap-2 pr-6">
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
Cost
|
||||
</div>
|
||||
</div>
|
||||
<template
|
||||
v-if="
|
||||
|
||||
@@ -9,7 +9,7 @@ defineProps<{
|
||||
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
||||
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
|
||||
<dd class="text-xl text-text-primary pt-1 font-medium">
|
||||
{{ value ?? '--' }}
|
||||
<slot>{{ value ?? '--' }}</slot>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +36,7 @@ useFocus(tagNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Update Tag </span>
|
||||
|
||||
@@ -50,7 +50,7 @@ useFocus(taskNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Task </span>
|
||||
|
||||
@@ -35,7 +35,7 @@ useFocus(taskNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Update Task </span>
|
||||
|
||||
@@ -63,7 +63,7 @@ const max = computed(() => {
|
||||
});
|
||||
|
||||
const backgroundColor = useCssVariable('--theme-color-card-background');
|
||||
const borderColor = useCssVariable('--color-border');
|
||||
const borderColor = useCssVariable('--color-border-secondary');
|
||||
const labelColor = useCssVariable('--color-text-secondary');
|
||||
const chartColorRaw = useCssVariable('--theme-color-chart');
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
@@ -101,6 +103,8 @@ const { data: totalWeeklyBillableTime } = useQuery({
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
const { data: totalWeeklyBillableAmount } = useQuery({
|
||||
queryKey: ['totalWeeklyBillableAmount', organizationId],
|
||||
queryFn: () => {
|
||||
@@ -110,7 +114,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
|
||||
},
|
||||
});
|
||||
},
|
||||
enabled: computed(() => !!organizationId.value),
|
||||
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
|
||||
@@ -270,7 +274,11 @@ const option = computed(() => {
|
||||
)
|
||||
: '--'
|
||||
" />
|
||||
<StatCard v-if="billableRatesLocked" title="Billable Amount">
|
||||
<UpgradeLockedBadge />
|
||||
</StatCard>
|
||||
<StatCard
|
||||
v-else
|
||||
title="Billable Amount"
|
||||
:value="
|
||||
totalWeeklyBillableAmount
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||
import {
|
||||
@@ -126,6 +128,8 @@ const showBillableRate = computed(() => {
|
||||
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
||||
);
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,7 +155,10 @@ const showBillableRate = computed(() => {
|
||||
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
||||
<div class="pl-6">Name</div>
|
||||
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">Duration</div>
|
||||
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
||||
<div v-if="showBillableRate" class="flex items-center justify-end gap-2 pr-6">
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
Cost
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -179,7 +179,7 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="emit('cancel')" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="emit('cancel')">
|
||||
<template #title>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Coffee class="w-5 h-5 text-text-secondary" />
|
||||
|
||||
@@ -23,7 +23,7 @@ import { PanelLeft } from '@lucide/vue';
|
||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import { nextTick, onMounted, provide, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, provide, ref } from 'vue';
|
||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||
import { initializeStores } from '@/utils/init';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
@@ -37,7 +37,9 @@ import {
|
||||
canViewReport,
|
||||
canViewTags,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import type { User } from '@/types/models';
|
||||
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||
@@ -88,6 +90,14 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
||||
|
||||
provide('organization', organization);
|
||||
|
||||
const showBillableRatesUpgradeModal = ref(false);
|
||||
provide(billableRatesLockKey, {
|
||||
locked: computed(() => !canUseBillableRates()),
|
||||
requestUpgrade: () => {
|
||||
showBillableRatesUpgradeModal.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
useTheme();
|
||||
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
||||
@@ -377,4 +387,7 @@ const page = usePage<{
|
||||
<NotificationContainer></NotificationContainer>
|
||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||
<CommandPaletteProvider></CommandPaletteProvider>
|
||||
<UpgradeModal v-model:show="showBillableRatesUpgradeModal">
|
||||
<strong>Billable Rates</strong> are only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||
@@ -80,6 +82,8 @@ const shownTasks = computed(() => {
|
||||
return task.project_id === projectId && task.is_done;
|
||||
});
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -117,11 +121,17 @@ const shownTasks = computed(() => {
|
||||
</li>
|
||||
</ol>
|
||||
<div class="px-4 space-x-1">
|
||||
<Badge v-if="project?.billable_rate">
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked"
|
||||
>Billable Rates</UpgradeLockedBadge
|
||||
>
|
||||
<Badge v-else-if="project?.billable_rate">
|
||||
{{ billableRateFormatted }}
|
||||
/ h
|
||||
</Badge>
|
||||
<Badge v-if="project?.is_billable && !project?.billable_rate">
|
||||
<Badge
|
||||
v-if="
|
||||
project?.is_billable && !project?.billable_rate && !billableRatesLocked
|
||||
">
|
||||
Default Rate
|
||||
</Badge>
|
||||
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ArrowDownTrayIcon,
|
||||
LockClosedIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import Pagination from '@/Components/Common/Pagination.vue';
|
||||
import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -10,10 +10,12 @@ import { storeToRefs } from 'pinia';
|
||||
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { fetchOrganization, updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||
const saving = ref(false);
|
||||
const organizationBody = ref<UpdateOrganizationBody>({
|
||||
name: '',
|
||||
@@ -75,9 +77,8 @@ function checkForConfirmationModal() {
|
||||
<Checkbox
|
||||
v-if="organization"
|
||||
id="organizationShowBillableRatesToEmployees"
|
||||
v-model:checked="
|
||||
organizationBody.employees_can_see_billable_rates
|
||||
"></Checkbox>
|
||||
v-model:checked="organizationBody.employees_can_see_billable_rates"
|
||||
:disabled="billableRatesLocked"></Checkbox>
|
||||
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
||||
>Show Billable Rates to Employees</FieldLabel
|
||||
>
|
||||
@@ -85,7 +86,10 @@ function checkForConfirmationModal() {
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
|
||||
>Upgrade</PrimaryButton
|
||||
>
|
||||
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.22",
|
||||
"version": "0.0.23",
|
||||
"description": "Package containing the solidtime ui components",
|
||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||
"module": "./dist/solidtime-ui-lib.js",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CommandShortcut,
|
||||
} from '../command';
|
||||
import { cn } from '../utils/cn';
|
||||
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
|
||||
import type {
|
||||
CommandPaletteCommand,
|
||||
CommandPaletteGroup,
|
||||
@@ -36,6 +37,8 @@ const emit = defineEmits<{
|
||||
select: [command: CommandPaletteCommand | EntitySearchResult];
|
||||
}>();
|
||||
|
||||
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||
|
||||
// Non-empty groups for rendering
|
||||
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
|
||||
|
||||
@@ -71,7 +74,9 @@ watch(open, (isOpen) => {
|
||||
)
|
||||
">
|
||||
<DialogContent
|
||||
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
|
||||
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus">
|
||||
<CommandRoot
|
||||
v-model:search-term="searchTerm"
|
||||
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'submit']);
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
@@ -24,12 +24,7 @@ const close = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:max-width="maxWidth"
|
||||
:closeable="closeable"
|
||||
@close="close"
|
||||
@submit="emit('submit')">
|
||||
<Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
|
||||
<div class="px-4 lg:px-6 py-4">
|
||||
<div class="text-lg font-medium text-text-primary" role="heading">
|
||||
<slot name="title" />
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '..';
|
||||
import UpgradeLockedField from './UpgradeLockedField.vue';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
@@ -20,6 +22,8 @@ const model = defineModel<number | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const { locked } = useBillableRatesLock();
|
||||
|
||||
const billableRateInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(billableRateInput, { initialValue: props.focus });
|
||||
|
||||
@@ -30,7 +34,9 @@ function formatValue(modelValue: number | null) {
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<UpgradeLockedField v-if="locked" />
|
||||
<NumberField
|
||||
v-else
|
||||
:id="name"
|
||||
ref="billableRateInput"
|
||||
:model-value="formatValue(model)"
|
||||
|
||||
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const { requestUpgrade } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="billable_rates_locked"
|
||||
class="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input-border bg-input-background px-3 text-left text-sm text-text-tertiary shadow-sm transition hover:border-border-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click.prevent.stop="requestUpgrade">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<LockClosedIcon class="w-3.5 shrink-0 text-icon-default" />
|
||||
<span class="truncate"><slot>Billable rates</slot></span>
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 rounded border border-border-secondary bg-secondary px-2 py-0.5 text-xs font-semibold text-text-secondary">
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogContent, DialogFooter } from './dialog/index';
|
||||
import { computed, nextTick } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'submit']);
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const close = () => {
|
||||
if (props.closeable) {
|
||||
@@ -25,25 +25,6 @@ const close = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Ctrl+Enter (Cmd+Enter on macOS) submits the modal from any focused element inside it.
|
||||
// Handled in the capture phase so child elements (buttons, dropdown triggers, inputs with
|
||||
// their own Enter handlers) never see the keystroke and cannot open or double-submit.
|
||||
async function onKeydownCapture(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey) || event.isComposing) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Inputs like the time and duration fields commit their value on blur, so blur first
|
||||
// and let the resulting model updates settle before submitting.
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement) {
|
||||
active.blur();
|
||||
}
|
||||
await nextTick();
|
||||
emit('submit');
|
||||
}
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
return {
|
||||
sm: 'sm:max-w-sm',
|
||||
@@ -58,7 +39,7 @@ const maxWidthClass = computed(() => {
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="close">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<div class="min-w-0" @keydown.capture="onKeydownCapture">
|
||||
<div class="min-w-0">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
ChevronRightIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { buttonVariants } from '@/packages/ui/src';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from './Buttons/index';
|
||||
import { cn } from './utils/cn';
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
const page = defineModel<number>('page', { default: 1 });
|
||||
@@ -72,7 +72,7 @@ const currentClientName = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Project </span>
|
||||
|
||||
@@ -10,12 +10,15 @@ import {
|
||||
} from '@/packages/ui/src/tooltip';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const props = defineProps<{
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
}>();
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
type RateType = 'default-rate' | 'custom-rate';
|
||||
|
||||
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
||||
@@ -91,7 +94,12 @@ const emit = defineEmits(['submit']);
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<BillableRateInput
|
||||
v-if="billableRatesLocked"
|
||||
v-model="displayedRate"
|
||||
:currency="currency"
|
||||
name="billableRate" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Select v-model="rateType">
|
||||
<SelectTrigger id="billableRateType">
|
||||
<SelectValue />
|
||||
|
||||
@@ -30,7 +30,7 @@ useFocus(tagNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Tags </span>
|
||||
|
||||
@@ -78,7 +78,7 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
|
||||
<Coffee class="w-5 h-5" />
|
||||
|
||||
@@ -126,7 +126,7 @@ const billableProxy = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create manual time entry </span>
|
||||
|
||||
@@ -164,7 +164,7 @@ const typeProxy = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Edit time entry </span>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@/packages/api/src';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
|
||||
import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
@@ -61,13 +60,6 @@ const projectId = ref<string | null>(null);
|
||||
const billable = ref<boolean | undefined>(undefined);
|
||||
const selectedTags = ref<string[]>([]);
|
||||
|
||||
// Clearing the project puts both fields back to "leave unchanged", the same state the form
|
||||
// returns to after a successful submit.
|
||||
function resetProject() {
|
||||
projectId.value = null;
|
||||
taskId.value = undefined;
|
||||
}
|
||||
|
||||
const timeEntryBillable = computed({
|
||||
get: () => {
|
||||
if (billable.value === undefined) {
|
||||
@@ -155,7 +147,7 @@ const showBreakWarning = computed(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Update {{ timeEntries.length }} time entries </span>
|
||||
@@ -190,59 +182,42 @@ const showBreakWarning = computed(
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="project">Project</FieldLabel>
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<!-- the dropdown declares its own `class` prop, which goes to the
|
||||
trigger button, so the growing has to happen on a wrapper -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="projectId"
|
||||
v-model:task="taskId"
|
||||
variant="input"
|
||||
align="start"
|
||||
size="default"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:currency="currency"
|
||||
:organization-billable-rate="organizationBillableRate"
|
||||
:can-create-project
|
||||
empty-placeholder="Select project..."
|
||||
:enable-estimated-time
|
||||
:projects="projects"
|
||||
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<button
|
||||
v-if="projectId !== null"
|
||||
type="button"
|
||||
data-testid="project_reset_button"
|
||||
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
|
||||
@click="resetProject">
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="projectId"
|
||||
v-model:task="taskId"
|
||||
variant="input"
|
||||
align="start"
|
||||
size="default"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:currency="currency"
|
||||
:organization-billable-rate="organizationBillableRate"
|
||||
:can-create-project
|
||||
empty-placeholder="Select project..."
|
||||
allow-reset
|
||||
:enable-estimated-time
|
||||
:projects="projects"
|
||||
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Tag</FieldLabel>
|
||||
<div class="flex items-center space-x-5">
|
||||
<!-- the dropdown root is `min-w-0`, so as a flex item it would shrink
|
||||
below its trigger and let the button overflow into the checkbox -->
|
||||
<div class="shrink-0">
|
||||
<TagDropdown
|
||||
v-model="selectedTags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:show-no-tag-option="false">
|
||||
<template #trigger>
|
||||
<Button variant="input" :disabled="removeAllTags">
|
||||
<TagIcon class="h-4 text-icon-default" />
|
||||
<span v-if="selectedTags.length > 0">
|
||||
Set {{ selectedTags.length }} tags
|
||||
</span>
|
||||
<span v-else>Select Tags...</span>
|
||||
</Button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</div>
|
||||
<div class="flex space-x-5">
|
||||
<TagDropdown
|
||||
v-model="selectedTags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:show-no-tag-option="false">
|
||||
<template #trigger>
|
||||
<Button variant="input" :disabled="removeAllTags">
|
||||
<TagIcon class="h-4 text-icon-default" />
|
||||
<span v-if="selectedTags.length > 0">
|
||||
Set {{ selectedTags.length }} tags
|
||||
</span>
|
||||
<span v-else>Select Tags...</span>
|
||||
</Button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
|
||||
<FieldLabel for="no_tags">Remove all tags</FieldLabel>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
|
||||
import { PlusCircleIcon, MinusIcon } from '@heroicons/vue/16/solid';
|
||||
import { PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
@@ -58,6 +58,7 @@ const props = withDefaults(
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
emptyPlaceholder?: string;
|
||||
allowReset?: boolean;
|
||||
noProjectValue?: string | null;
|
||||
enableEstimatedTime: boolean;
|
||||
organizationBillableRate: number | null;
|
||||
@@ -69,6 +70,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
emptyPlaceholder: 'No Project',
|
||||
allowReset: false,
|
||||
noProjectValue: NO_PROJECT_ID,
|
||||
variant: 'ghost',
|
||||
align: 'center',
|
||||
@@ -556,6 +558,12 @@ function selectProject(projectId: string) {
|
||||
emit('changed', project.value, task.value);
|
||||
}
|
||||
|
||||
function resetProject() {
|
||||
project.value = null;
|
||||
task.value = null;
|
||||
emit('changed', project.value, task.value);
|
||||
}
|
||||
|
||||
const showCreateProject = ref(false);
|
||||
</script>
|
||||
|
||||
@@ -563,28 +571,40 @@ const showCreateProject = ref(false);
|
||||
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<Button
|
||||
:variant="props.variant"
|
||||
:size="props.size"
|
||||
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: selectedProjectColor }"></div>
|
||||
<span class="truncate shrink-[1] text-text-primary">{{
|
||||
selectedProjectName
|
||||
}}</span>
|
||||
<template v-if="currentTask">
|
||||
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
|
||||
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
||||
</template>
|
||||
<template v-if="selectedClientName">
|
||||
<span class="dark:text-text-tertiary text-text-quaternary shrink-0">•</span>
|
||||
<span
|
||||
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
|
||||
>{{ selectedClientName }}</span
|
||||
>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:variant="props.variant"
|
||||
:size="props.size"
|
||||
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: selectedProjectColor }"></div>
|
||||
<span class="truncate shrink-[1] text-text-primary">{{
|
||||
selectedProjectName
|
||||
}}</span>
|
||||
<template v-if="currentTask">
|
||||
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
|
||||
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
||||
</template>
|
||||
<template v-if="selectedClientName">
|
||||
<span class="dark:text-text-tertiary text-text-quaternary shrink-0"
|
||||
>•</span
|
||||
>
|
||||
<span
|
||||
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
|
||||
>{{ selectedClientName }}</span
|
||||
>
|
||||
</template>
|
||||
</Button>
|
||||
<button
|
||||
v-if="allowReset && project !== null"
|
||||
type="button"
|
||||
data-testid="project_reset_button"
|
||||
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
|
||||
@click.stop="resetProject">
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #content>
|
||||
|
||||
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||
import { useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||
|
||||
const { requestUpgrade } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="billable_rates_locked_badge"
|
||||
class="inline-flex items-center space-x-1 rounded border border-border-secondary bg-secondary px-2 py-1 hover:border-border-tertiary hover:bg-tertiary"
|
||||
@click.prevent.stop="requestUpgrade">
|
||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||
<span class="text-xs font-semibold text-text-secondary"><slot>Upgrade</slot></span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '../utils/cn';
|
||||
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
|
||||
import {
|
||||
DialogContent,
|
||||
type DialogContentEmits,
|
||||
@@ -20,6 +21,10 @@ const delegatedProps = computed(() => {
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
// Forwarded consumer listeners run first, so a consumer can still take over
|
||||
// by calling preventDefault() on close-auto-focus.
|
||||
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +41,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus">
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '../utils/cn';
|
||||
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
|
||||
import { X } from '@lucide/vue';
|
||||
import {
|
||||
DialogClose,
|
||||
@@ -22,6 +23,10 @@ const delegatedProps = computed(() => {
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
// Forwarded consumer listeners run first, so a consumer can still take over
|
||||
// by calling preventDefault() on close-auto-focus.
|
||||
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,6 +41,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
|
||||
@@ -21,6 +21,8 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
|
||||
export type { ActivityPeriod } from './FullCalendar/activityTypes';
|
||||
export { cn } from './utils/cn';
|
||||
export { useCssVariable } from './utils/useCssVariable';
|
||||
export { billableRatesLockKey, useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||
export type { BillableRatesLock } from './utils/useBillableRatesLock';
|
||||
|
||||
import Badge from './Badge.vue';
|
||||
import Button from './Buttons/Button.vue';
|
||||
@@ -32,6 +34,7 @@ import InputLabel from './Input/InputLabel.vue';
|
||||
import TextInput from './Input/TextInput.vue';
|
||||
import LoadingSpinner from './LoadingSpinner.vue';
|
||||
import Modal from './Modal.vue';
|
||||
import Pagination from './Pagination.vue';
|
||||
import ProjectBadge from './Project/ProjectBadge.vue';
|
||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
||||
@@ -257,6 +260,7 @@ export {
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
Pagination,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
|
||||
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal file
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
|
||||
|
||||
export interface BillableRatesLock {
|
||||
locked: ComputedRef<boolean>;
|
||||
requestUpgrade: () => void;
|
||||
}
|
||||
|
||||
export const billableRatesLockKey: InjectionKey<BillableRatesLock> = Symbol('billableRatesLock');
|
||||
|
||||
/**
|
||||
* Whether billable rates are locked behind a plan upgrade for the current organization.
|
||||
*
|
||||
* The app layout provides the lock state and opens the upgrade dialog. Without a
|
||||
* provider (e.g. public report views or other consumers of this package) billable
|
||||
* rates count as unlocked.
|
||||
*/
|
||||
export function useBillableRatesLock(): BillableRatesLock {
|
||||
return inject(billableRatesLockKey, {
|
||||
locked: computed(() => false),
|
||||
requestUpgrade: () => {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useDialogFocusRestore } from './useDialogFocusRestore';
|
||||
|
||||
function closeEvent() {
|
||||
return new CustomEvent('focusScope.autoFocusOnUnmount', { cancelable: true });
|
||||
}
|
||||
|
||||
describe('useDialogFocusRestore', () => {
|
||||
it('restores focus to the element focused when the dialog opened', () => {
|
||||
vi.useFakeTimers();
|
||||
const button = document.createElement('button');
|
||||
document.body.appendChild(button);
|
||||
button.focus();
|
||||
|
||||
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||
onOpenAutoFocus();
|
||||
button.blur();
|
||||
|
||||
const event = closeEvent();
|
||||
onCloseAutoFocus(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
button.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('focuses nothing when the dialog was opened with nothing focused', () => {
|
||||
vi.useFakeTimers();
|
||||
const stale = document.createElement('button');
|
||||
document.body.appendChild(stale);
|
||||
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||
|
||||
// First open from the button, then close
|
||||
stale.focus();
|
||||
onOpenAutoFocus();
|
||||
onCloseAutoFocus(closeEvent());
|
||||
vi.runAllTimers();
|
||||
stale.blur();
|
||||
|
||||
// Second open from the body must not refocus the stale button
|
||||
onOpenAutoFocus();
|
||||
const event = closeEvent();
|
||||
onCloseAutoFocus(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
|
||||
stale.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not restore focus to an element that was removed', () => {
|
||||
vi.useFakeTimers();
|
||||
const button = document.createElement('button');
|
||||
document.body.appendChild(button);
|
||||
button.focus();
|
||||
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||
onOpenAutoFocus();
|
||||
button.remove();
|
||||
|
||||
onCloseAutoFocus(closeEvent());
|
||||
vi.runAllTimers();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('leaves control to a consumer that already prevented the event', () => {
|
||||
vi.useFakeTimers();
|
||||
const button = document.createElement('button');
|
||||
document.body.appendChild(button);
|
||||
button.focus();
|
||||
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||
onOpenAutoFocus();
|
||||
button.blur();
|
||||
|
||||
const event = closeEvent();
|
||||
event.preventDefault();
|
||||
onCloseAutoFocus(event);
|
||||
vi.runAllTimers();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
|
||||
button.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
41
resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
Normal file
41
resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Restores focus to the element that was focused when a dialog opened.
|
||||
*
|
||||
* reka-ui remembers the active element at content mount as the dialog's
|
||||
* "trigger" (only when it is not the body) and refocuses it on every close,
|
||||
* but it never clears that value. A dialog opened while nothing is focused
|
||||
* (e.g. the command palette via Cmd+K from the body) therefore refocuses
|
||||
* whatever triggered a *previous* open. Bind these handlers to
|
||||
* `DialogContent`'s `open-auto-focus` / `close-auto-focus` events to restore
|
||||
* exactly the previously focused element, or nothing.
|
||||
*
|
||||
* A consumer handler that already called `preventDefault()` on
|
||||
* `close-auto-focus` keeps control; this composable then does nothing.
|
||||
*/
|
||||
export function useDialogFocusRestore() {
|
||||
let previouslyFocused: HTMLElement | null = null;
|
||||
|
||||
function onOpenAutoFocus() {
|
||||
const active = document.activeElement;
|
||||
previouslyFocused =
|
||||
active instanceof HTMLElement && active !== document.body ? active : null;
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event) {
|
||||
const target = previouslyFocused;
|
||||
previouslyFocused = null;
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
// Prevents both FocusScope's default restore and reka-ui's trigger refocus
|
||||
event.preventDefault();
|
||||
// Same tick reka-ui uses, so the dialog content is fully gone first
|
||||
setTimeout(() => {
|
||||
if (target?.isConnected) {
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return { onOpenAutoFocus, onCloseAutoFocus };
|
||||
}
|
||||
@@ -70,3 +70,13 @@ export function isAllowedToPerformPremiumAction() {
|
||||
(isBillingActivated() && isInTrial())
|
||||
);
|
||||
}
|
||||
|
||||
export function canUseBillableRates() {
|
||||
const page = usePage<{
|
||||
billing: {
|
||||
can_use_billable_rates?: boolean;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
return !isBillingActivated() || page.props.billing?.can_use_billable_rates !== false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { api } from '@/packages/api/src';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
@@ -57,7 +57,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
||||
deep: true,
|
||||
});
|
||||
@@ -89,23 +88,12 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
try {
|
||||
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
||||
if (timeEntriesResponse?.data) {
|
||||
if (timeEntriesResponse.data) {
|
||||
currentTimeEntry.value = timeEntriesResponse.data;
|
||||
if (
|
||||
currentTimeEntry.value.start !== '' &&
|
||||
currentTimeEntry.value.end === null
|
||||
) {
|
||||
startLiveTimer();
|
||||
}
|
||||
} else {
|
||||
// No active time entry on server
|
||||
// Only reset if we had a previously started timer (has an ID)
|
||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||
stopLiveTimer();
|
||||
}
|
||||
}
|
||||
currentTimeEntry.value = timeEntriesResponse.data;
|
||||
} else if (currentTimeEntry.value.id !== '') {
|
||||
// No active time entry on server
|
||||
// Only reset if we had a previously started timer (has an ID)
|
||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||
}
|
||||
} catch {
|
||||
// API error (e.g., 404 when no active time entry)
|
||||
@@ -113,7 +101,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||
stopLiveTimer();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -294,6 +281,18 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
return isActive.value && currentTimeEntry.value.type === 'break';
|
||||
});
|
||||
|
||||
watch(
|
||||
isActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
startLiveTimer();
|
||||
} else {
|
||||
stopLiveTimer();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function setActiveState(newState: boolean) {
|
||||
if (newState) {
|
||||
startLiveTimer();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
@@ -43,4 +44,43 @@ class PasswordConfirmationTest extends TestCase
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed_if_a_placeholder_user_with_the_same_email_exists(): void
|
||||
{
|
||||
// Arrange
|
||||
// Placeholders created by an import have no password at all. The placeholder is created
|
||||
// first so that it would be returned by an unordered lookup by email.
|
||||
$email = 'shared@example.com';
|
||||
User::factory()->placeholder()->create(['email' => $email, 'password' => null]);
|
||||
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('secret-password')]);
|
||||
|
||||
// Act
|
||||
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||
'password' => 'secret-password',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
$this->assertTrue($this->app['session']->has('auth.password_confirmed_at'));
|
||||
}
|
||||
|
||||
public function test_password_confirmation_ignores_the_password_of_a_placeholder_user_with_the_same_email(): void
|
||||
{
|
||||
// Arrange
|
||||
// Placeholders created by removing a member copy the password hash as of the removal,
|
||||
// so the placeholder holds a password that the real user has since replaced.
|
||||
$email = 'shared@example.com';
|
||||
User::factory()->placeholder()->create(['email' => $email, 'password' => Hash::make('outdated-password')]);
|
||||
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('current-password')]);
|
||||
|
||||
// Act
|
||||
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||
'password' => 'outdated-password',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSessionHasErrors();
|
||||
$this->assertFalse($this->app['session']->has('auth.password_confirmed_at'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Tests\Feature;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
@@ -93,4 +94,62 @@ class PasswordResetTest extends TestCase
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_reset_targets_the_real_user_when_a_placeholder_user_with_the_same_email_exists(): void
|
||||
{
|
||||
|
||||
Notification::fake();
|
||||
|
||||
// The placeholder is created first so that it would be returned by an unordered lookup by email
|
||||
$email = 'shared@example.com';
|
||||
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
|
||||
$user = User::factory()->create(['email' => $email]);
|
||||
$placeholderPasswordBefore = $placeholder->password;
|
||||
|
||||
$response = $this->post('/forgot-password', [
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
Notification::assertNotSentTo($placeholder, ResetPassword::class);
|
||||
Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($email) {
|
||||
$response = $this->post('/reset-password', [
|
||||
'token' => $notification->token,
|
||||
'email' => $email,
|
||||
'password' => 'new-password-123',
|
||||
'password_confirmation' => 'new-password-123',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$placeholder->refresh();
|
||||
$user->refresh();
|
||||
$this->assertSame($placeholderPasswordBefore, $placeholder->password);
|
||||
$this->assertTrue(Hash::check('new-password-123', $user->password));
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => $email,
|
||||
'password' => 'new-password-123',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_password_reset_link_is_not_sent_if_only_a_placeholder_user_with_the_email_exists(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$placeholder = User::factory()->placeholder()->create();
|
||||
|
||||
$response = $this->post('/forgot-password', [
|
||||
'email' => $placeholder->email,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('email');
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ abstract class TestCase extends BaseTestCase
|
||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,6 +100,18 @@ abstract class TestCase extends BaseTestCase
|
||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||
});
|
||||
}
|
||||
|
||||
protected function actAsOrganizationWithoutBillableRates(): void
|
||||
{
|
||||
$this->mock(BillingContract::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('hasSubscription')->andReturn(false);
|
||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||
$mock->shouldReceive('canUseBillableRates')->andReturn(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
90
tests/Unit/Auth/ActiveUserProviderTest.php
Normal file
90
tests/Unit/Auth/ActiveUserProviderTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Api\V1\Controller;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Passport\Passport;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[UsesClass(Controller::class)]
|
||||
class BillableRateRestrictionEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_project_store_fails_with_billable_rate_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:create',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => 'Project',
|
||||
'color' => '#ef5350',
|
||||
'client_id' => null,
|
||||
'is_billable' => true,
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('error', true);
|
||||
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
|
||||
$this->assertDatabaseMissing(Project::class, [
|
||||
'name' => 'Project',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_project_store_without_billable_rate_works_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:create',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => 'Project',
|
||||
'color' => '#ef5350',
|
||||
'client_id' => null,
|
||||
'is_billable' => true,
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'name' => 'Project',
|
||||
'is_billable' => true,
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_project_update_keeps_existing_billable_rate_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create([
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => 'RenamedKeep',
|
||||
'color' => $project->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => true,
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => 'RenamedChange',
|
||||
'color' => $project->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => true,
|
||||
'billable_rate' => 20000,
|
||||
]);
|
||||
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => 'RenamedRemove',
|
||||
'color' => $project->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => true,
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$responseKeep->assertStatus(200);
|
||||
$responseKeep->assertJsonPath('data.name', 'RenamedKeep');
|
||||
$responseChange->assertStatus(400);
|
||||
$responseChange->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
|
||||
$responseRemove->assertStatus(200);
|
||||
$project->refresh();
|
||||
$this->assertSame('RenamedRemove', $project->name);
|
||||
$this->assertSame(10000, $project->billable_rate);
|
||||
}
|
||||
|
||||
public function test_project_update_without_billable_rate_does_not_change_project_rate_or_time_entries_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create([
|
||||
'billable_rate' => 10000,
|
||||
'is_billable' => true,
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->create([
|
||||
'billable' => true,
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => 'Renamed',
|
||||
'color' => $project->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => true,
|
||||
'is_archived' => true,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$project->refresh();
|
||||
$this->assertSame('Renamed', $project->name);
|
||||
$this->assertTrue($project->is_archived);
|
||||
$this->assertSame(10000, $project->billable_rate);
|
||||
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
|
||||
}
|
||||
|
||||
public function test_organization_update_fails_to_set_billable_rate_or_employee_visibility_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'organizations:update',
|
||||
]);
|
||||
$data->organization->employees_can_see_billable_rates = false;
|
||||
$data->organization->save();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||
'name' => 'Organization',
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||
'name' => 'Organization',
|
||||
'employees_can_see_billable_rates' => true,
|
||||
]);
|
||||
$responseUnchanged = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||
'name' => 'Organization',
|
||||
'billable_rate' => null,
|
||||
'employees_can_see_billable_rates' => false,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$responseRate->assertStatus(400);
|
||||
$responseVisibility->assertStatus(400);
|
||||
$responseUnchanged->assertStatus(200);
|
||||
$data->organization->refresh();
|
||||
$this->assertNull($data->organization->billable_rate);
|
||||
$this->assertFalse($data->organization->employees_can_see_billable_rates);
|
||||
}
|
||||
|
||||
public function test_member_update_fails_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:update',
|
||||
]);
|
||||
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
|
||||
$this->assertNull($member->refresh()->billable_rate);
|
||||
}
|
||||
|
||||
public function test_project_member_store_and_update_fail_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$responseStore = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'member_id' => $otherMember->getKey(),
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
$responseUpdate = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$responseStore->assertStatus(400);
|
||||
$responseUpdate->assertStatus(400);
|
||||
$this->assertNull($projectMember->refresh()->billable_rate);
|
||||
$this->assertDatabaseMissing(ProjectMember::class, [
|
||||
'member_id' => $otherMember->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
$start = Carbon::now()->timezone($data->user->timezone)->subDays(2);
|
||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($start, 3600)->create([
|
||||
'billable' => true,
|
||||
'billable_rate' => 10000,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||
$data->organization->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('data.seconds', 3600);
|
||||
$response->assertJsonPath('data.cost', null);
|
||||
}
|
||||
|
||||
public function test_total_weekly_billable_amount_is_forbidden_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$data = $this->createUserWithPermission([
|
||||
'charts:view:own',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,29 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_import_fails_if_data_exceeds_maximum_size(): void
|
||||
{
|
||||
// Arrange
|
||||
config(['import.max_data_size' => 16]);
|
||||
$user = $this->createUserWithPermission([
|
||||
'import',
|
||||
]);
|
||||
$this->mock(ImportService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldNotReceive('import');
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [
|
||||
'type' => 'toggl_time_entries',
|
||||
'data' => base64_encode(str_repeat('a', 15)),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['data']);
|
||||
}
|
||||
|
||||
public function test_import_return_error_message_if_import_fails(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -105,6 +105,9 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'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
|
||||
|
||||
@@ -35,6 +35,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
@@ -47,6 +48,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
||||
->where('billing.has_trial', false)
|
||||
->where('billing.trial_until', null)
|
||||
->where('billing.is_blocked', false)
|
||||
->where('billing.can_use_billable_rates', true)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
||||
$mock->shouldReceive('hasTrial')->andReturn(true);
|
||||
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
@@ -73,6 +76,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
||||
->where('billing.has_trial', true)
|
||||
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
|
||||
->where('billing.is_blocked', false)
|
||||
->where('billing.can_use_billable_rates', true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,23 @@ class UserModelTest extends ModelTestAbstract
|
||||
$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
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -412,10 +412,11 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
||||
$this->assertDatabaseHas(Organization::class, [
|
||||
'id' => $organizationOfA->getKey(),
|
||||
]);
|
||||
// The placeholder user should exist with current_team_id set to the org where they are a placeholder
|
||||
// The placeholder user should exist and must not reference the deleted organization,
|
||||
// which is what caused the foreign key violation in #989
|
||||
$placeholderUser = User::query()->where('is_placeholder', true)->first();
|
||||
$this->assertNotNull($placeholderUser);
|
||||
$this->assertSame($organizationOfA->getKey(), $placeholderUser->current_team_id);
|
||||
$this->assertNull($placeholderUser->current_team_id);
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'id' => $memberBInOrgA->getKey(),
|
||||
'user_id' => $placeholderUser->getKey(),
|
||||
|
||||
@@ -41,6 +41,7 @@ class ImportServiceTest extends TestCase
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$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
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Enums\TimeEntryType;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
@@ -42,6 +44,31 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
||||
$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
|
||||
{
|
||||
// Arrange
|
||||
@@ -77,6 +104,34 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
||||
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
|
||||
}
|
||||
|
||||
public function test_import_drops_billable_rates_if_organization_can_not_use_billable_rates(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actAsOrganizationWithoutBillableRates();
|
||||
$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);
|
||||
Queue::fake([
|
||||
RecalculateSpentTimeForProject::class,
|
||||
RecalculateSpentTimeForTask::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(3, Project::query()->whereBelongsTo($organization, 'organization')->whereNull('billable_rate')->count());
|
||||
$this->assertSame(1, ProjectMember::query()->whereBelongsToOrganization($organization)->whereNull('billable_rate')->count());
|
||||
$timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get();
|
||||
$this->assertCount(2, $timeEntries);
|
||||
foreach ($timeEntries as $timeEntry) {
|
||||
$this->assertNull($timeEntry->billable_rate);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_with_type_column_imports_breaks(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -39,6 +39,31 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
||||
$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
|
||||
{
|
||||
// Arrange
|
||||
|
||||
254
tests/Unit/Service/Import/Importers/ZipImportHelperTest.php
Normal file
254
tests/Unit/Service/Import/Importers/ZipImportHelperTest.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?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,6 +13,7 @@ use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
@@ -64,6 +65,48 @@ class MemberServiceTest extends TestCaseWithDatabase
|
||||
$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
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user