Compare commits

..

2 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,8 +51,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
$query->where('is_placeholder', '=', false);
})
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View File

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

View File

@@ -4,6 +4,7 @@ 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;
@@ -158,7 +159,7 @@ class ChartController extends Controller
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$showBillableRate = $this->canSeeBillableRates($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.');
}

View File

@@ -4,8 +4,6 @@ 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;
@@ -51,30 +49,4 @@ 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;
}
}
}

View File

@@ -83,7 +83,6 @@ 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);

View File

@@ -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->canSeeBillableRates($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new OrganizationResource($organization, $showBillableRate);
}
@@ -54,10 +54,7 @@ class OrganizationController extends Controller
if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency();
}
if ($request->getEmployeesCanSeeBillableRates() !== null && $request->getEmployeesCanSeeBillableRates() !== $organization->employees_can_see_billable_rates) {
if (! $this->canUseBillableRates($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getEmployeesCanManageTasks() !== null) {
@@ -86,7 +83,6 @@ 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();
}

View File

@@ -65,7 +65,7 @@ class ProjectController extends Controller
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->canSeeBillableRates($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new ProjectCollection($projects, $showBillableRate);
}
@@ -99,7 +99,6 @@ 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');
@@ -126,7 +125,6 @@ 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');
@@ -141,17 +139,14 @@ class ProjectController extends Controller
}
$oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
// 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();
}
$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 !== $project->billable_rate) {
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project);
}
if ($clientIdChanged) {

View File

@@ -65,7 +65,6 @@ 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) {
@@ -99,7 +98,6 @@ 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();

View File

@@ -5,6 +5,7 @@ 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;
@@ -239,7 +240,7 @@ class TimeEntryController extends Controller
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->canSeeBillableRates($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -385,7 +386,7 @@ class TimeEntryController extends Controller
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $this->canSeeBillableRates($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
@@ -440,7 +441,7 @@ class TimeEntryController extends Controller
}
$debug = $request->getDebug();
$user = $this->user();
$showBillableRate = $this->canSeeBillableRates($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup();
$subGroup = $request->getSubGroup();

View File

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

View File

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

View File

@@ -57,7 +57,6 @@ 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'),

View File

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

View File

@@ -58,9 +58,6 @@ class UserUpdateRequest extends BaseFormRequest
'week_start' => [
Rule::enum(Weekday::class),
],
'send_time_entry_still_running_email' => [
'boolean',
],
];
}
@@ -84,13 +81,6 @@ class UserUpdateRequest extends BaseFormRequest
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
}
public function getSendTimeEntryStillRunningEmail(): ?bool
{
return $this->has('send_time_entry_still_running_email')
? $this->boolean('send_time_entry_still_running_email')
: null;
}
public function hasPhotoKey(): bool
{
return $this->has('photo');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ 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;
@@ -72,15 +71,9 @@ 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);
@@ -103,11 +96,7 @@ 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);
@@ -131,7 +120,7 @@ abstract class DefaultImporter implements ImporterContract
'uuid',
],
], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0 || ! $this->canUseBillableRates) {
if ($project->billable_rate === 0) {
$project->billable_rate = null;
}
});
@@ -145,7 +134,7 @@ abstract class DefaultImporter implements ImporterContract
'max:2147483647',
],
], beforeSave: function (ProjectMember $projectMember): void {
if ($projectMember->billable_rate === 0 || ! $this->canUseBillableRates) {
if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null;
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('send_time_entry_still_running_email');
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

2197
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,7 +7,7 @@ import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import Pagination from '@/packages/ui/src/Pagination.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {

View File

@@ -21,7 +21,6 @@ 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()!);
@@ -72,8 +71,6 @@ function saveWithChecks() {
}
}
const { locked: billableRatesLocked } = useBillableRatesLock();
const billableRateSelect = ref<MemberBillableKey>('default-rate');
onMounted(() => {
@@ -162,12 +159,7 @@ const roleDescription = computed(() => {
<FieldLabel :icon="BillableIcon" for="billableRateType"
>Billable Rate</FieldLabel
>
<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">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="billableRateSelect">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

@@ -1,6 +1,4 @@
<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';
@@ -14,8 +12,6 @@ const props = defineProps<{
defineEmits<{
sort: [column: SortColumn];
}>();
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -37,10 +33,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
column="billable_rate"
v-bind="props"
@sort="$emit('sort', $event)">
<span class="inline-flex items-center gap-2 whitespace-nowrap">
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</span>
Billable Rate
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
Status

View File

@@ -1,5 +1,4 @@
<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';
@@ -26,7 +25,6 @@ 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 {
@@ -73,8 +71,6 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -94,8 +90,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="member.billable_rate">
<span v-if="member.billable_rate">
{{
formatCents(
member.billable_rate,

View File

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

View File

@@ -23,10 +23,8 @@ 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 });
@@ -50,11 +48,6 @@ 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
@@ -63,7 +56,7 @@ async function submit() {
}, 0);
return;
}
await updateProject(props.originalProject.id, getUpdateBody());
await updateProject(props.originalProject.id, project.value);
show.value = false;
}
@@ -79,7 +72,7 @@ const currentClientName = computed(() => {
});
async function submitBillableRate() {
await updateProject(props.originalProject.id, getUpdateBody());
await updateProject(props.originalProject.id, project.value);
show.value = false;
showBillableRateModal.value = false;
}

View File

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

View File

@@ -1,6 +1,4 @@
<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';
@@ -27,8 +25,6 @@ const sortState = computed(() => ({
function handleSort(column: SortColumn) {
emit('sort', column);
}
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -54,10 +50,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
column="billable_rate"
v-bind="sortState"
@sort="handleSort">
<span class="inline-flex items-center gap-2 whitespace-nowrap">
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</span>
Billable Rate
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
Status

View File

@@ -1,5 +1,4 @@
<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';
@@ -16,7 +15,6 @@ 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';
@@ -54,11 +52,8 @@ 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, {
...project,
...(billableRatesLocked.value ? {} : { billable_rate: billableRate }),
...props.project,
is_archived: !props.project.is_archived,
});
}
@@ -83,8 +78,6 @@ const billableRateInfo = computed(() => {
});
const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -136,8 +129,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
</div>
<div

View File

@@ -1,9 +1,5 @@
<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>
@@ -11,10 +7,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<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 flex items-center gap-2">
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</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">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>

View File

@@ -1,5 +1,4 @@
<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';
@@ -11,7 +10,6 @@ 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;
@@ -35,8 +33,6 @@ const member = computed(() => {
return members.value.find((member) => member.id === props.projectMember.member_id);
});
const showEditModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -52,20 +48,17 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<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>
{{
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member?.role ?? '') }}

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import {
ChartBarIcon,
ArrowDownTrayIcon,
@@ -292,8 +290,6 @@ const tableData = computed(() => {
};
});
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -420,12 +416,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
Duration
</div>
<div
v-if="showBillableRate"
class="flex items-center justify-end gap-2 pr-6">
<UpgradeLockedBadge v-if="billableRatesLocked" />
Cost
</div>
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
</div>
<template
v-if="

View File

@@ -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">
<slot>{{ value ?? '--' }}</slot>
{{ value ?? '--' }}
</dd>
</div>
</template>

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
<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';
@@ -103,8 +101,6 @@ const { data: totalWeeklyBillableTime } = useQuery({
staleTime: 1000 * 30, // 30 seconds
});
const { locked: billableRatesLocked } = useBillableRatesLock();
const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => {
@@ -114,7 +110,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
},
});
},
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
enabled: computed(() => !!organizationId.value),
staleTime: 1000 * 30, // 30 seconds
});
@@ -274,11 +270,7 @@ const option = computed(() => {
)
: '--'
" />
<StatCard v-if="billableRatesLocked" title="Billable Amount">
<UpgradeLockedBadge />
</StatCard>
<StatCard
v-else
title="Billable Amount"
:value="
totalWeeklyBillableAmount

View File

@@ -1,6 +1,4 @@
<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 {
@@ -128,8 +126,6 @@ const showBillableRate = computed(() => {
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
);
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -155,10 +151,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
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="flex items-center justify-end gap-2 pr-6">
<UpgradeLockedBadge v-if="billableRatesLocked" />
Cost
</div>
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
</div>
<div

View File

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

View File

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

View File

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

View File

@@ -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 { computed, nextTick, onMounted, provide, ref } from 'vue';
import { nextTick, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -37,9 +37,7 @@ import {
canViewReport,
canViewTags,
} from '@/utils/permissions';
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
@@ -90,14 +88,6 @@ 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
@@ -263,9 +253,7 @@ const page = usePage<{
v-if="isInvoicingActivated() && canViewInvoices()"
title="Invoices"
:icon="DocumentTextIcon"
:current="
route().current('invoices') || route().current('invoices.*')
"
:current="route().current('invoices')"
href="/invoices"></NavigationSidebarItem>
</ul>
</nav>
@@ -387,7 +375,4 @@ 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
<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';
@@ -82,8 +80,6 @@ const shownTasks = computed(() => {
return task.project_id === projectId && task.is_done;
});
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -121,17 +117,11 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
</li>
</ol>
<div class="px-4 space-x-1">
<UpgradeLockedBadge v-if="billableRatesLocked"
>Billable Rates</UpgradeLockedBadge
>
<Badge v-else-if="project?.billable_rate">
<Badge v-if="project?.billable_rate">
{{ billableRateFormatted }}
/ h
</Badge>
<Badge
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
<Badge v-if="project?.is_billable && !project?.billable_rate">
Default Rate
</Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>

View File

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

View File

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

View File

@@ -10,12 +10,10 @@ 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: '',
@@ -77,8 +75,9 @@ function checkForConfirmationModal() {
<Checkbox
v-if="organization"
id="organizationShowBillableRatesToEmployees"
v-model:checked="organizationBody.employees_can_see_billable_rates"
:disabled="billableRatesLocked"></Checkbox>
v-model:checked="
organizationBody.employees_can_see_billable_rates
"></Checkbox>
<FieldLabel for="organizationShowBillableRatesToEmployees"
>Show Billable Rates to Employees</FieldLabel
>
@@ -86,10 +85,7 @@ function checkForConfirmationModal() {
</div>
</template>
<template #actions>
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
>Upgrade</PrimaryButton
>
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
</template>
</FormSection>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,6 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '..';
import UpgradeLockedField from './UpgradeLockedField.vue';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const props = defineProps<{
name: string;
@@ -22,8 +20,6 @@ const model = defineModel<number | null>({
default: null,
});
const { locked } = useBillableRatesLock();
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
@@ -34,9 +30,7 @@ function formatValue(modelValue: number | null) {
<template>
<div class="relative">
<UpgradeLockedField v-if="locked" />
<NumberField
v-else
:id="name"
ref="billableRateInput"
:model-value="formatValue(model)"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,15 +10,12 @@ 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');
@@ -94,12 +91,7 @@ const emit = defineEmits(['submit']);
</Field>
<Field>
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
<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">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="rateType">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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