mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-11 01:32:16 +01:00
Compare commits
1 Commits
d0f2ee7d7e
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc927b6a9 |
@@ -5,8 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
use Illuminate\Auth\Passwords\PasswordBroker;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
@@ -20,6 +24,16 @@ class ResetUserPassword implements ResetsUserPasswords
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
|
||||
/** @var PasswordBroker $broker */
|
||||
$broker = Password::broker(config('fortify.passwords'));
|
||||
$broker->deleteToken($user);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('This password reset link is invalid.')],
|
||||
]);
|
||||
}
|
||||
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TagMatchType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Contains = 'contains';
|
||||
|
||||
case NotContains = 'not_contains';
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class ReportController extends Controller
|
||||
$filter->addBillable($properties->billable);
|
||||
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
||||
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
||||
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
|
||||
$filter->addTagIdsFilter($properties->tagIds?->toArray());
|
||||
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
||||
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
||||
$timeEntriesQuery = $filter->get();
|
||||
|
||||
@@ -96,7 +96,6 @@ class ReportController extends Controller
|
||||
$properties->setClientIds($request->input('properties.client_ids', null));
|
||||
$properties->setProjectIds($request->input('properties.project_ids', null));
|
||||
$properties->setTagIds($request->input('properties.tag_ids', null));
|
||||
$properties->setTagMatchType($request->getPropertyTagMatchType());
|
||||
$properties->setTaskIds($request->input('properties.task_ids', null));
|
||||
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
|
||||
$timezone = $user->timezone;
|
||||
|
||||
@@ -203,7 +203,7 @@ class TimeEntryController extends Controller
|
||||
$filter->addMemberIdFilter($member);
|
||||
$filter->addMemberIdsFilter($request->input('member_ids'));
|
||||
$filter->addProjectIdsFilter($request->input('project_ids'));
|
||||
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
||||
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||
$filter->addBillableFilter($request->input('billable'));
|
||||
@@ -559,7 +559,7 @@ class TimeEntryController extends Controller
|
||||
$filter->addMemberIdFilter($member);
|
||||
$filter->addMemberIdsFilter($request->input('member_ids'));
|
||||
$filter->addProjectIdsFilter($request->input('project_ids'));
|
||||
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
||||
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||
$filter->addBillableFilter($request->input('billable'));
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
@@ -125,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
}
|
||||
},
|
||||
],
|
||||
'properties.tag_match_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
'properties.task_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
@@ -255,15 +249,6 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||
}
|
||||
|
||||
public function getPropertyTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->input('properties.tag_match_type'));
|
||||
}
|
||||
|
||||
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
@@ -140,10 +139,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -251,15 +246,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
@@ -126,10 +125,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -213,15 +208,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -111,10 +110,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -220,15 +215,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
@@ -104,10 +103,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -195,15 +190,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -56,8 +56,6 @@ class DetailedReportResource extends BaseResource
|
||||
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
||||
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||
/** @var string|null $tag_match_type Tag match type */
|
||||
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||
/** @var string|null $rounding_type Rounding type for time entries */
|
||||
|
||||
@@ -145,11 +145,21 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
public function isSuperAdmin(): bool
|
||||
{
|
||||
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
public function hasLocalPassword(): bool
|
||||
{
|
||||
return is_string($this->password) && $this->password !== '';
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return $this->isSuperAdmin();
|
||||
}
|
||||
|
||||
public function isMemberOfOrganization(Organization $organization): bool
|
||||
{
|
||||
if ($this->relationLoaded('organizations')) {
|
||||
|
||||
@@ -26,6 +26,7 @@ use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
use Nwidart\Modules\Laravel\Module as LaravelModule;
|
||||
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
@@ -91,22 +92,77 @@ class AdminPanelProvider extends PanelProvider
|
||||
$modules = Module::allEnabled();
|
||||
|
||||
foreach ($modules as $module) {
|
||||
$moduleNamespace = $this->getModuleAppNamespace($module);
|
||||
|
||||
$panel->discoverResources(
|
||||
in: module_path($module->getName(), 'app/Filament/Resources'),
|
||||
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
|
||||
for: $moduleNamespace.'\\Filament\\Resources'
|
||||
);
|
||||
|
||||
$panel->discoverPages(
|
||||
in: module_path($module->getName(), 'app/Filament/Pages'),
|
||||
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
|
||||
for: $moduleNamespace.'\\Filament\\Pages'
|
||||
);
|
||||
|
||||
$panel->discoverWidgets(
|
||||
in: module_path($module->getName(), 'app/Filament/Widgets'),
|
||||
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
|
||||
for: $moduleNamespace.'\\Filament\\Widgets'
|
||||
);
|
||||
}
|
||||
|
||||
return $panel;
|
||||
}
|
||||
|
||||
/** @var array<string, string> Cache of module name => resolved app namespace. */
|
||||
private static array $moduleAppNamespaces = [];
|
||||
|
||||
private function getModuleAppNamespace(LaravelModule $module): string
|
||||
{
|
||||
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the PHP namespace mapped to a module's app/ directory so the
|
||||
* Filament panel can discover its Resources/Pages/Widgets under the right
|
||||
* namespace.
|
||||
*
|
||||
* Two module layouts currently coexist in this repo:
|
||||
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
|
||||
* app/ — e.g. "Extensions\SSO\" => app/, so classes are
|
||||
* Extensions\SSO\Filament\... (this is the current convention).
|
||||
* - the older layout: an "...\App" namespace maps to app/ — e.g.
|
||||
* "Extensions\Billing\App\" => app/, so classes are
|
||||
* Extensions\Billing\App\Filament\...
|
||||
*
|
||||
* The package's own namespace derivation assumes the v12 (bare) layout and
|
||||
* would mis-resolve the legacy modules, so we read each module's composer
|
||||
* PSR-4 map and use whichever namespace actually points at app/. The legacy
|
||||
* "...\App" shape is only a fallback for when composer is missing/unreadable.
|
||||
* Once every module adopts the bare layout this collapses to
|
||||
* config('modules.namespace').'\\'.$module->getName().
|
||||
*/
|
||||
private function resolveModuleAppNamespace(LaravelModule $module): string
|
||||
{
|
||||
$fallback = 'Extensions\\'.$module->getName().'\\App';
|
||||
|
||||
$composerPath = module_path($module->getName(), 'composer.json');
|
||||
$psr4 = [];
|
||||
if (is_file($composerPath)) {
|
||||
$composer = json_decode((string) file_get_contents($composerPath), true);
|
||||
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
|
||||
}
|
||||
|
||||
foreach ((array) $psr4 as $namespace => $path) {
|
||||
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
|
||||
return rtrim($namespace, '\\');
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function normalizeComposerPath(mixed $path): string
|
||||
{
|
||||
return trim(str_replace('\\', '/', (string) $path), '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,73 @@ use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Dummy bcrypt hash compared against when no user matches the submitted
|
||||
* email. Hash::check is run against it so login takes the same time whether
|
||||
* or not the email exists — otherwise an unknown email would skip the
|
||||
* (deliberately slow) hash and return faster, letting an attacker enumerate
|
||||
* registered accounts by timing the response. The plaintext is irrelevant:
|
||||
* it is only ever checked against attacker-supplied input and never matches.
|
||||
*/
|
||||
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
|
||||
|
||||
/**
|
||||
* Authorization rules applied AFTER the password is verified. Each rule
|
||||
* receives the authenticated user + request and returns whether the login
|
||||
* may proceed; any rule returning false denies it. This is an extension
|
||||
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
|
||||
* instead of replacing this credential check — which would silently drift
|
||||
* from the host logic the next time it changes.
|
||||
*
|
||||
* @var array<int, \Closure(User, Request): bool>
|
||||
*/
|
||||
protected static array $loginRules = [];
|
||||
|
||||
/**
|
||||
* Authorization rules applied before a password reset is completed. Rules
|
||||
* receive the user being reset + submitted input and return whether the
|
||||
* local reset flow may set a new password for that account.
|
||||
*
|
||||
* @var array<int, \Closure(User, array<string, mixed>): bool>
|
||||
*/
|
||||
protected static array $passwordResetRules = [];
|
||||
|
||||
/**
|
||||
* Register an additional rule that gates password login (see $loginRules).
|
||||
*
|
||||
* @param \Closure(User, Request): bool $rule
|
||||
*/
|
||||
public static function authenticateUsingRule(\Closure $rule): void
|
||||
{
|
||||
static::$loginRules[] = $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an additional rule that gates password reset completion.
|
||||
*
|
||||
* @param \Closure(User, array<string, mixed>): bool $rule
|
||||
*/
|
||||
public static function resetPasswordUsingRule(\Closure $rule): void
|
||||
{
|
||||
static::$passwordResetRules[] = $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given user may complete the local password reset flow.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public static function canResetPassword(User $user, array $input = []): bool
|
||||
{
|
||||
foreach (static::$passwordResetRules as $rule) {
|
||||
if (! $rule($user, $input)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
@@ -92,7 +159,23 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
->where('is_placeholder', '=', false)
|
||||
->first();
|
||||
|
||||
if ($user !== null && Hash::check($request->password, $user->password)) {
|
||||
// Always run the hash check — against the real hash, or a dummy when
|
||||
// there is no user — so login timing is identical either way (see
|
||||
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
|
||||
// have password = null) fail here, so they cannot password-login.
|
||||
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
|
||||
|
||||
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
|
||||
|
||||
if ($user !== null && $passwordIsValid) {
|
||||
// Credentials are valid; now apply any registered authorization
|
||||
// rules (e.g. SSO enforcement may still block password login).
|
||||
foreach (static::$loginRules as $rule) {
|
||||
if (! $rule($user, $request)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
@@ -57,8 +56,6 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public ?Collection $tagIds = null;
|
||||
|
||||
public ?TagMatchType $tagMatchType = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
@@ -118,7 +115,6 @@ class ReportPropertiesDto implements Castable
|
||||
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
||||
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
||||
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
||||
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
||||
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||
@@ -148,7 +144,6 @@ class ReportPropertiesDto implements Castable
|
||||
'clientIds' => $value->clientIds?->toArray(),
|
||||
'projectIds' => $value->projectIds?->toArray(),
|
||||
'tagIds' => $value->tagIds?->toArray(),
|
||||
'tagMatchType' => $value->tagMatchType?->value,
|
||||
'taskIds' => $value->taskIds?->toArray(),
|
||||
'group' => $value->group->value,
|
||||
'subGroup' => $value->subGroup->value,
|
||||
@@ -221,11 +216,6 @@ class ReportPropertiesDto implements Castable
|
||||
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
||||
}
|
||||
|
||||
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
||||
{
|
||||
$this->tagMatchType = $tagMatchType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $taskIds
|
||||
*/
|
||||
|
||||
@@ -116,12 +116,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Description'];
|
||||
if (isset($record['Billable'])) {
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
@@ -221,6 +219,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
'Group',
|
||||
'Email',
|
||||
'Tags',
|
||||
'Billable',
|
||||
'Start Date',
|
||||
'Start Time',
|
||||
'End Date',
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -193,21 +192,15 @@ class TimeEntryFilter
|
||||
/**
|
||||
* @param array<string>|null $tagIds
|
||||
*/
|
||||
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
|
||||
public function addTagIdsFilter(?array $tagIds): self
|
||||
{
|
||||
if ($tagIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$tagMatchType ??= TagMatchType::Contains;
|
||||
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
||||
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
|
||||
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
|
||||
if (count($tagIds) === 0 && ! $includeNone) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||
foreach ($tagIds as $tagId) {
|
||||
$builder->orWhereJsonContains('tags', $tagId);
|
||||
}
|
||||
@@ -216,18 +209,7 @@ class TimeEntryFilter
|
||||
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if ($tagMatchType === TagMatchType::NotContains) {
|
||||
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
|
||||
$builder->whereNot($tagCondition);
|
||||
if (! $includeNone) {
|
||||
$builder->orWhereNull('tags');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$this->builder->where($tagCondition);
|
||||
}
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,56 @@ class UserService
|
||||
}
|
||||
$user->save();
|
||||
|
||||
$this->createDefaultOrganizationForUser(
|
||||
$user,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user without a password (e.g. provisioned via SSO). Such users
|
||||
* can only authenticate through a linked identity provider.
|
||||
*/
|
||||
public function createPasswordlessUser(
|
||||
string $name,
|
||||
string $email,
|
||||
string $timezone,
|
||||
Weekday $weekStart,
|
||||
?string $currency,
|
||||
bool $verifyEmail = false
|
||||
): User {
|
||||
$user = new User;
|
||||
$user->name = $name;
|
||||
$user->email = strtolower($email);
|
||||
$user->password = null;
|
||||
$user->timezone = $timezone;
|
||||
$user->week_start = $weekStart;
|
||||
if ($verifyEmail) {
|
||||
$user->email_verified_at = Carbon::now();
|
||||
}
|
||||
$user->save();
|
||||
|
||||
$this->createDefaultOrganizationForUser($user, $currency);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createDefaultOrganizationForUser(
|
||||
User $user,
|
||||
?string $currency,
|
||||
?NumberFormat $numberFormat = null,
|
||||
?CurrencyFormat $currencyFormat = null,
|
||||
?DateFormat $dateFormat = null,
|
||||
?IntervalFormat $intervalFormat = null,
|
||||
?TimeFormat $timeFormat = null,
|
||||
): void {
|
||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||
|
||||
if ($organizations->isEmpty()) {
|
||||
@@ -64,8 +114,6 @@ class UserService
|
||||
);
|
||||
$this->switchCurrentOrganization($user, $organization);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
@@ -23,7 +22,6 @@ const selectedProjects = defineModel<string[]>('selectedProjects', { required: t
|
||||
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
|
||||
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
|
||||
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
|
||||
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
|
||||
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
|
||||
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
|
||||
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
|
||||
@@ -95,44 +93,6 @@ async function createTag(name: string) {
|
||||
title="Tags"
|
||||
:icon="TagIcon" />
|
||||
</template>
|
||||
<template #content-before-list>
|
||||
<div class="mt-2 border-b border-card-background-separator pb-2">
|
||||
<div
|
||||
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
|
||||
Match
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-md px-2 py-1.5 text-left text-sm font-medium"
|
||||
:class="
|
||||
tagMatchType === 'contains'
|
||||
? 'bg-card-background-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-card-background-active'
|
||||
"
|
||||
@click="
|
||||
tagMatchType = 'contains';
|
||||
emit('submit');
|
||||
">
|
||||
Contains
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-md px-2 py-1.5 text-left text-sm font-medium"
|
||||
:class="
|
||||
tagMatchType === 'not_contains'
|
||||
? 'bg-card-background-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-card-background-active'
|
||||
"
|
||||
@click="
|
||||
tagMatchType = 'not_contains';
|
||||
emit('submit');
|
||||
">
|
||||
Does Not Contain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
|
||||
<Select v-model="billable" @update:model-value="emit('submit')">
|
||||
|
||||
@@ -49,7 +49,6 @@ import type { ExportFormat } from '@/types/reporting';
|
||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
@@ -68,7 +67,6 @@ const selectedProjects = ref<string[]>([]);
|
||||
const selectedMembers = ref<string[]>([]);
|
||||
const selectedTasks = ref<string[]>([]);
|
||||
const selectedClients = ref<string[]>([]);
|
||||
const tagMatchType = ref<TagMatchType>('contains');
|
||||
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
@@ -124,7 +122,6 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
||||
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
||||
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
|
||||
billable: billable.value !== null ? billable.value : undefined,
|
||||
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
@@ -369,7 +366,6 @@ const tableData = computed(() => {
|
||||
v-model:selected-tasks="selectedTasks"
|
||||
v-model:selected-clients="selectedClients"
|
||||
v-model:selected-tags="selectedTags"
|
||||
v-model:tag-match-type="tagMatchType"
|
||||
v-model:billable="billable"
|
||||
v-model:rounding-enabled="roundingEnabled"
|
||||
v-model:rounding-type="roundingType"
|
||||
|
||||
@@ -7,10 +7,16 @@ 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';
|
||||
|
||||
defineProps({
|
||||
canResetPassword: Boolean,
|
||||
status: String,
|
||||
});
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
canResetPassword?: boolean;
|
||||
status?: string;
|
||||
}>(),
|
||||
{
|
||||
canResetPassword: false,
|
||||
status: '',
|
||||
}
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
@@ -28,8 +34,8 @@ const submit = () => {
|
||||
};
|
||||
|
||||
const page = usePage<{
|
||||
flash: {
|
||||
message: string;
|
||||
flash?: {
|
||||
message?: string;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -61,6 +67,9 @@ const page = usePage<{
|
||||
{{ page.props.flash?.message }}
|
||||
</div>
|
||||
|
||||
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
|
||||
<slot name="error" />
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<Field>
|
||||
<FieldLabel for="email">Email</FieldLabel>
|
||||
@@ -103,5 +112,8 @@ const page = usePage<{
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
|
||||
<slot name="alternatives" />
|
||||
</AuthenticationCard>
|
||||
</template>
|
||||
|
||||
@@ -67,7 +67,6 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
|
||||
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
|
||||
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
|
||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
@@ -85,7 +84,6 @@ const selectedProjects = ref<string[]>([]);
|
||||
const selectedMembers = ref<string[]>([]);
|
||||
const selectedTasks = ref<string[]>([]);
|
||||
const selectedClients = ref<string[]>([]);
|
||||
const tagMatchType = ref<TagMatchType>('contains');
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
||||
@@ -117,7 +115,6 @@ function getFilterAttributes() {
|
||||
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
||||
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
|
||||
billable: billable.value !== null ? billable.value : undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||
@@ -340,7 +337,6 @@ async function downloadExport(format: ExportFormat) {
|
||||
v-model:selected-tasks="selectedTasks"
|
||||
v-model:selected-clients="selectedClients"
|
||||
v-model:selected-tags="selectedTags"
|
||||
v-model:tag-match-type="tagMatchType"
|
||||
v-model:billable="billable"
|
||||
v-model:rounding-enabled="roundingEnabled"
|
||||
v-model:rounding-type="roundingType"
|
||||
|
||||
@@ -10,35 +10,73 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
||||
import { type DefineComponent } from 'vue';
|
||||
import { setupPrefetching } from '@/utils/prefetch';
|
||||
|
||||
interface ExtensionManifest {
|
||||
name?: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
const pinia = createPinia();
|
||||
const queryClient = new QueryClient();
|
||||
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, ExtensionManifest>;
|
||||
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
|
||||
const extensionPages = import.meta.glob<DefineComponent>([
|
||||
'../../extensions/**/resources/js/Pages/**/*.vue',
|
||||
'!**/BillingPortal.vue',
|
||||
]);
|
||||
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
|
||||
(directories, [path, manifest]) => {
|
||||
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
|
||||
const extensionDirectory = match?.[1];
|
||||
|
||||
if (extensionDirectory === undefined) {
|
||||
return directories;
|
||||
}
|
||||
|
||||
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
|
||||
if (typeof key !== 'string' || key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
directories[key] = extensionDirectory;
|
||||
directories[key.toLowerCase()] = extensionDirectory;
|
||||
}
|
||||
|
||||
return directories;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
function resolveExtensionDirectory(moduleName: string): string {
|
||||
return (
|
||||
extensionDirectories[moduleName] ??
|
||||
extensionDirectories[moduleName.toLowerCase()] ??
|
||||
moduleName
|
||||
);
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => `${title} - ${appName}`,
|
||||
resolve: (name) => {
|
||||
if (name.includes('Invoicing::')) {
|
||||
const [module, page] = name.split('::');
|
||||
// "Module::Page" (both halves present) resolves to that extension's page
|
||||
// directory; everything else is a host page under resources/js/Pages.
|
||||
const [module, ...pageSegments] = name.split('::');
|
||||
const page = pageSegments.join('::');
|
||||
|
||||
const pagePath = module
|
||||
? `../../extensions/${module}/resources/js/Pages/${page}.vue`
|
||||
: `./Pages/${page}.vue`;
|
||||
if (module && page) {
|
||||
const extensionDirectory = resolveExtensionDirectory(module);
|
||||
const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
|
||||
|
||||
// BillingPortal is a Vue 2 Component and therefore should not be imported
|
||||
const pages = module
|
||||
? import.meta.glob<DefineComponent>([
|
||||
'../../extensions/**/resources/js/Pages/*.vue',
|
||||
'!**/BillingPortal.vue',
|
||||
])
|
||||
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
|
||||
|
||||
return resolvePageComponent(pagePath, pages);
|
||||
} else {
|
||||
return resolvePageComponent(
|
||||
`./Pages/${name}.vue`,
|
||||
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
||||
);
|
||||
return resolvePageComponent(pagePath, extensionPages);
|
||||
}
|
||||
|
||||
return resolvePageComponent(
|
||||
`./Pages/${name}.vue`,
|
||||
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
||||
);
|
||||
},
|
||||
setup({ el, App, props, plugin }) {
|
||||
const app = createApp({ render: () => h(App, props) });
|
||||
|
||||
@@ -448,7 +448,6 @@ const ReportStoreRequest = z
|
||||
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
tag_match_type: z.enum(['contains', 'not_contains']).optional(),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
group: TimeEntryAggregationType,
|
||||
sub_group: TimeEntryAggregationType,
|
||||
@@ -482,7 +481,6 @@ const DetailedReportResource = z
|
||||
client_ids: z.union([z.array(z.string()), z.null()]),
|
||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]),
|
||||
rounding_type: z.union([z.string(), z.null()]),
|
||||
rounding_minutes: z.union([z.number(), z.null()]),
|
||||
@@ -3786,11 +3784,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
@@ -4172,11 +4165,6 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
@@ -4371,11 +4359,6 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
@@ -4504,11 +4487,6 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
|
||||
@@ -114,7 +114,6 @@ const showCreateTagModal = ref(false);
|
||||
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||
placeholder="Search for a Tag..." />
|
||||
</ComboboxAnchor>
|
||||
<slot name="content-before-list"></slot>
|
||||
<ComboboxContent
|
||||
:dismiss-able="false"
|
||||
position="inline"
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
|
||||
export type TagMatchType = 'contains' | 'not_contains';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
||||
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
|
||||
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
|
||||
|
@@ -41,30 +41,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
|
||||
$this->checkTimeEntries($testScenario, false, true);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_with_special_characters_description_succeeds(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
|
||||
/**
|
||||
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
|
||||
*/
|
||||
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object
|
||||
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object
|
||||
{
|
||||
$users = User::all();
|
||||
$this->assertCount(2, $users);
|
||||
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
|
||||
$this->assertSame('#ef5350', $project1->color);
|
||||
$this->assertSame(null, $project1->billable_rate);
|
||||
// Project for Big Company
|
||||
$this->assertSame(! $billableDefault, $project2->is_billable);
|
||||
$this->assertSame(true, $project2->is_billable);
|
||||
$this->assertSame(false, $project2->is_public);
|
||||
$this->assertSame('#ec407a', $project2->color);
|
||||
$this->assertSame(10001, $project2->billable_rate);
|
||||
// Project (Archived)
|
||||
$this->assertSame(! $billableDefault, $project3->is_billable);
|
||||
$this->assertSame(true, $project3->is_billable);
|
||||
$this->assertSame(true, $project3->is_public);
|
||||
$this->assertSame('#6a407f', $project3->color);
|
||||
$this->assertSame(null, $project3->billable_rate);
|
||||
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
|
||||
/**
|
||||
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
|
||||
*/
|
||||
protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void
|
||||
protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void
|
||||
{
|
||||
$timeEntries = TimeEntry::all();
|
||||
if ($secondRun) {
|
||||
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertSame(! $billableDefault, $timeEntry2->billable);
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertTrue($timeEntry2->is_imported);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
@@ -135,4 +137,60 @@ class UserServiceTest extends TestCase
|
||||
$this->assertSame(Role::Owner->value, $newMember->role);
|
||||
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
||||
}
|
||||
|
||||
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
|
||||
{
|
||||
// Arrange — an accepted invitation exists for the email (e.g. the user
|
||||
// followed the invite link, then signs up via SSO). Casing differs to
|
||||
// prove the email is normalised before the invitation is matched.
|
||||
$organization = Organization::factory()->create();
|
||||
OrganizationInvitation::factory()
|
||||
->forOrganization($organization)
|
||||
->role(Role::Employee)
|
||||
->accepted()
|
||||
->create([
|
||||
'email' => 'invitee@example.com',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$user = $this->userService->createPasswordlessUser(
|
||||
'Invitee',
|
||||
'Invitee@Example.com',
|
||||
'UTC',
|
||||
Weekday::Monday,
|
||||
null,
|
||||
);
|
||||
|
||||
// Assert — invitation is materialised, no personal organization is created
|
||||
$this->assertNull($user->password);
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'email' => 'invitee@example.com',
|
||||
]);
|
||||
$user->refresh();
|
||||
$this->assertSame(1, $user->organizations()->count());
|
||||
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
|
||||
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
|
||||
$this->assertSame(Role::Employee->value, $member->role);
|
||||
}
|
||||
|
||||
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
|
||||
{
|
||||
// Act
|
||||
$user = $this->userService->createPasswordlessUser(
|
||||
'Solo User',
|
||||
'solo@example.com',
|
||||
'UTC',
|
||||
Weekday::Monday,
|
||||
null,
|
||||
);
|
||||
|
||||
// Assert — a personal organization is created, owned by the user and set current
|
||||
$user->refresh();
|
||||
$this->assertNull($user->password);
|
||||
$this->assertSame(1, $user->organizations()->count());
|
||||
$organization = $user->organizations()->first();
|
||||
$this->assertTrue($organization->personal_team);
|
||||
$this->assertSame($user->getKey(), $organization->user_id);
|
||||
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user