Compare commits

..

22 Commits

Author SHA1 Message Date
Constantin Graf
3161bbef4a Add invite-only registration mode 2026-08-31 13:14:09 +02:00
Constantin Graf
303de4930d Add invite-only registration mode
Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage.
2026-08-25 18:20:45 +02:00
Constantin Graf
b2849ec04a Fixed code formatting 2026-08-25 13:35:32 +02:00
Constantin Graf
bd69cf478c Bump billing extension to v0.0.5 2026-08-25 13:35:32 +02:00
Constantin Graf
5197135d00 Updated service extension 2026-08-25 13:35:32 +02:00
Constantin Graf
b09b8649b8 Fixed typo in import 2026-08-25 12:32:30 +02:00
Constantin Graf
99fafdb8d6 Add database indexes 2026-08-25 12:32:30 +02:00
Gregor Vostrak
b02e49c7e0 improve modal scrolling behaviour on mobile 2026-08-24 21:46:46 +02:00
Gregor Vostrak
8609635d74 fix modal overflow on smaller viewports with dynamic viewport height 2026-08-22 17:43:33 +02:00
github-actions[bot]
5a07f798b3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/discussions/1188#discussioncomment-DC_kwDOLFnGEc4BFEiq
2026-08-21 13:22:28 +00:00
Gregor Vostrak
3af69830ee move api rate limit to app config, add fallback for empty env key 2026-08-20 17:40:33 +02:00
Darwin Correa
a050153bcd feat: make API rate limits configurable via env vars (#1204)
* feat: make API rate limits configurable via env vars
2026-08-20 17:22:24 +02:00
Gregor Vostrak
9d9731c7ce add formatting support for months grouping 2026-08-20 17:11:35 +02:00
Andrew Herron
dfe3206614 Add "Week" grouping option to reporting 2026-08-20 17:11:35 +02:00
Andrew Herron
97fd882878 Format date group labels in all aggregate exports 2026-08-20 17:11:35 +02:00
Andrew Herron
111e12df12 Add "Date" grouping option to reporting 2026-08-20 17:11:35 +02:00
Constantin Graf
de13c07855 Fixed return type in TrustHosts 2026-08-06 17:49:52 +02:00
Constantin Graf
29a2e994cd Fixed phpstan error in TrustHosts middleware 2026-08-06 17:06:34 +02:00
Constantin Graf
f6d886b218 Refactored TrustHostsTest; Added return types to TrustHosts 2026-08-06 17:01:46 +02:00
Gregor Vostrak
80d98b30a1 add custom error handling for host mismatch; ensure TrustHosts runs
before TrustProxies
2026-08-06 17:01:46 +02:00
Gregor Vostrak
32f2f1431b add TrustHosts middleware with exemption for healthchecks 2026-08-06 17:01:46 +02:00
Constantin Graf
8f6d584ee9 Fixed invoice tax rate 2026-07-30 21:16:06 +02:00
40 changed files with 1673 additions and 74 deletions

View File

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

1
.github/VOUCHED.td vendored
View File

@@ -18,6 +18,7 @@ bufferhead-code
candideu
kasparrosin
korridor
nikbucher tasks table sorting
onatcer
shrootbuck
smilebeda

View File

@@ -4,9 +4,11 @@ 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;
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
if (! config('app.enable_registration')) {
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
if ($registrationMode === RegistrationMode::Off) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [
$validated = Validator::make($input, [
'name' => [
'required',
'string',
@@ -60,6 +63,20 @@ 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

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

@@ -6,7 +6,10 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable;
class Handler extends ExceptionHandler
@@ -30,6 +33,29 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void {
//
});
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the
// SuspiciousOperationException into a BadRequestHttpException by the time
// renderables run, so we match that and inspect the original.
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
$previous = $e->getPrevious();
if (! $previous instanceof SuspiciousOperationException
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
return null; // any other bad request keeps the default response
}
$message = 'This hostname is not configured for this instance. '
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
if ($request->expectsJson()) {
return response()->json(['message' => $message], 400);
}
return response()->view('errors.untrusted-host', ['message' => $message], 400);
});
}
public function render($request, Throwable $e): Response|RedirectResponse

View File

@@ -535,7 +535,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
$path,
config('filesystems.private'),
$format->getExportPackageType(),

View File

@@ -36,6 +36,7 @@ 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

@@ -15,6 +15,7 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
@@ -47,6 +48,7 @@ class Kernel extends HttpKernel
*/
protected $middleware = [
ForceHttps::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Rejects requests whose Host is not trusted, preventing Host-header poisoning of
* generated URLs (password reset, SSO callback, invitations). Trusted = the
* APP_URL host and its subdomains, plus TRUSTED_HOSTS (for multi-host access such
* as a Tailscale name). Health-check endpoints are exempt (probed by IP).
*/
class TrustHosts extends BaseTrustHosts
{
/**
* @return array<int, string|null>
*/
public function hosts(): array
{
/** @var array<int, string> $configured */
$configured = config('app.trusted_hosts', []);
$extra = array_map(function (string $host): string {
$host = trim($host);
// "*.example.com" matches any subdomain, not the apex.
if (str_starts_with($host, '*.')) {
return '^.+\.'.preg_quote(substr($host, 2), '#').'$';
}
return '^'.preg_quote($host, '#').'$';
}, $configured);
return array_merge([$this->allSubdomainsOfApplicationUrl()], $extra);
}
/**
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, $next)
{
// Exempt health checks (probed by IP). Also reset the trusted hosts,
// since Octane leaks the static state across requests.
if ($request->is('health-check/*')) {
Request::setTrustedHosts([]);
return $next($request);
}
return parent::handle($request, $next);
}
}

View File

@@ -45,6 +45,7 @@ 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

@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
}
return $request->user()
? Limit::perMinute(200)->by($request->user()->id)
: Limit::perMinute(60)->by($request->ip());
? Limit::perMinute(config('app.api_rate_limit_authenticated_per_minute'))->by($request->user()->id)
: Limit::perMinute(config('app.api_rate_limit_guest_per_minute'))->by($request->ip());
});
$this->routes(function (): void {

View File

@@ -18,6 +18,22 @@ 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

@@ -8,12 +8,14 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Models\Organization;
use Brick\Math\BigDecimal;
use Brick\Money\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
use Illuminate\Support\Carbon;
class LocalizationService
{
@@ -152,6 +154,38 @@ class LocalizationService
return $date->format($this->dateFormat->toCarbonFormat());
}
/**
* Time group types have no server-side descriptor; their keys are ISO dates and are
* formatted here instead. A Week key is the first day of that week, so it renders as the
* range it covers. A Year key is already a bare year, so it is returned unchanged - it must
* not be parsed, Carbon reads a four digit string as a time of day.
*/
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
{
if ($key === null) {
return null;
}
if ($groupType === TimeEntryAggregationType::Day) {
return $this->formatDate(Carbon::parse($key));
}
if ($groupType === TimeEntryAggregationType::Week) {
$weekStart = Carbon::parse($key);
return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6));
}
if ($groupType === TimeEntryAggregationType::Month) {
// Note: the leading "!" resets all fields the format does not name. Without it the
// day of the month is taken from today, and a day that the parsed month does not
// have overflows the date into the next month.
return Carbon::createFromFormat('!Y-m', $key)->format('F Y');
}
return $key;
}
public function setDateFormat(DateFormat $dateFormat): void
{
$this->dateFormat = $dateFormat;

View File

@@ -6,6 +6,7 @@ namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Service\LocalizationService;
use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView;
@@ -48,6 +49,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private bool $showBillableRate;
private LocalizationService $localization;
/**
* @param array{
* grouped_type: string|null,
@@ -68,7 +71,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate, LocalizationService $localization)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
@@ -76,6 +79,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
$this->group = $group;
$this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
$this->localization = $localization;
}
public function view(): View
@@ -87,6 +91,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
'localization' => $this->localization,
]);
}

View File

@@ -75,11 +75,32 @@ return [
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Trusted Hosts
|--------------------------------------------------------------------------
|
| Additional hostnames (besides the APP_URL host and its subdomains) that
| the application is allowed to respond on. This is needed for multi-host
| setups, e.g. reaching the instance over both a public domain and a
| Tailscale name. A request arriving on any host that is neither APP_URL
| (nor a subdomain of it) nor listed here is rejected, which prevents
| Host-header poisoning of password reset and other out-of-band links.
|
| See App\Http\Middleware\TrustHosts.
|
*/
'trusted_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('TRUSTED_HOSTS', ''))
))),
'asset_url' => env('ASSET_URL'),
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
@@ -137,6 +158,21 @@ return [
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
/*
|--------------------------------------------------------------------------
| API Rate Limiting
|--------------------------------------------------------------------------
|
| The number of API requests allowed per minute, counted per user for
| authenticated requests and per IP address for guest requests. These
| limits are only enforced when the application runs in production.
|
*/
'api_rate_limit_authenticated_per_minute' => (int) (env('API_RATE_LIMIT_AUTH_PER_MINUTE') ?: 200),
'api_rate_limit_guest_per_minute' => (int) (env('API_RATE_LIMIT_GUEST_PER_MINUTE') ?: 60),
/*
|--------------------------------------------------------------------------
| Encryption Key

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use function Laravel\Prompts\info as consoleInfo;
return new class extends Migration
{
/**
* PostgreSQL cannot build or drop an index concurrently inside a transaction.
* Keeping this migration non-transactional prevents long write locks in production.
*
* @var bool
*/
public $withinTransaction = false;
/**
* Run the migrations.
*/
public function up(): void
{
// Tenant-scoped client pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)');
// Tenant-scoped project pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)');
// Speeds client relationship loads and the FK check when a client is deleted or its ID changes.
$this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)');
// Tenant-scoped task pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)');
// Speeds project task lists and the FK check when a project is deleted or its ID changes.
$this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)');
// Tenant-scoped tag pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)');
// Tenant-scoped report pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)');
// Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup.
$this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)');
// Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes.
$this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)');
// The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes.
$this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)');
$this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)');
// Supports organization deletion/current-team cleanup and the FK check on users.current_team_id.
$this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)');
// Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table.
$this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)');
// Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK.
$this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)');
// Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering.
$this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)');
// Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK.
$this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)');
// Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK.
$this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)');
// These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted.
$this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)');
$this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)');
$this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)');
// Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup.
$this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL');
// whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index.
$this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)');
// Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update.
$this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)');
$this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)');
// owner_id is already the leading column of oauth_clients_owner_id_owner_type_index.
$this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index');
// Public report lookup starts with the unique share_secret index; no query filters only by this boolean.
$this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$indexes = [
'clients_organization_created_id_index',
'projects_organization_created_id_index',
'projects_client_id_index',
'tasks_organization_created_id_index',
'tasks_project_id_index',
'tags_organization_created_id_index',
'reports_organization_created_id_index',
'members_organization_created_id_index',
'members_user_id_index',
'project_members_member_id_index',
'project_members_user_id_index',
'users_current_team_id_index',
'audits_created_at_index',
'time_entries_organization_start_id_index',
'time_entries_created_at_index',
'time_entries_user_organization_start_index',
'time_entries_member_start_index',
'time_entries_project_id_index',
'time_entries_task_id_index',
'time_entries_client_id_index',
'time_entries_active_member_index',
'time_entries_tags_gin_index',
'oauth_access_tokens_client_id_index',
'oauth_auth_codes_client_id_index',
];
$concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
foreach ($indexes as $index) {
$this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index);
}
$this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)');
$this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)');
}
private function runIndexOperation(string $operation, string $index, string $statement): void
{
$indexState = $this->indexState($index);
if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) {
$this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index));
return;
}
if ($operation === 'drop' && $indexState === null) {
$this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index));
return;
}
if ($operation === 'create' && $indexState !== null) {
$this->writeProgress(sprintf(
'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding',
$index,
$indexState['valid'] ? 'true' : 'false',
$indexState['ready'] ? 'true' : 'false',
));
$this->executeIndexStatement(
'drop incomplete',
$index,
(DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index),
);
}
$this->executeIndexStatement($operation, $index, $statement);
}
private function executeIndexStatement(string $operation, string $index, string $statement): void
{
$startedAt = microtime(true);
$this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index));
try {
DB::statement($statement);
} catch (Throwable $exception) {
$this->writeProgress(sprintf(
'Failed to %s index [%s] after %.2f seconds: %s',
$operation,
$index,
microtime(true) - $startedAt,
$exception->getMessage(),
));
throw $exception;
}
$this->writeProgress(sprintf(
'Finished %s index [%s] in %.2f seconds',
$operation === 'create' ? 'creating' : 'dropping',
$index,
microtime(true) - $startedAt,
));
}
/**
* @return array{valid: bool, ready: bool}|null
*/
private function indexState(string $index): ?array
{
$state = DB::selectOne(
<<<'SQL'
SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_namespace.nspname = current_schema()
AND pg_class.relname = ?
SQL,
[$index],
);
if ($state === null) {
return null;
}
return [
'valid' => (bool) $state->valid,
'ready' => (bool) $state->ready,
];
}
private function quoteIdentifier(string $identifier): string
{
return DB::connection()->getQueryGrammar()->wrap($identifier);
}
private function writeProgress(string $message): void
{
if (app()->runningUnitTests()) {
return;
}
$message = sprintf('[%s] %s', date(DATE_ATOM), $message);
Log::info($message);
consoleInfo($message);
}
};

View File

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

@@ -10,6 +10,7 @@ import {
createTimeEntryWithTagViaApi,
createTimeEntryWithBillableStatusViaApi,
createBareTimeEntryViaApi,
createTimeEntryOnDateViaApi,
createPublicProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
@@ -841,6 +842,127 @@ test('test that setting group by to current sub group triggers sub group fallbac
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
});
test('test that group by date groups the report by day and formats the date labels with organization settings', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by date',
duration: '1h',
});
// Go to reporting page
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
// Find the "Group by" selects within the reporting table
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
// Default state: group=Project
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
// Verify the API request contains the correct group parameter
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('day');
// The row label is rendered in the organization date format (D.M.YYYY)
await expect(
page.getByTestId('reporting_view').getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)
).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week requests week grouping and does not leak the raw group key', async ({
page,
ctx,
}) => {
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by week',
duration: '1h',
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('week');
// The raw group key is the first day of the week and must not leak through.
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week labels a week spanning new year with a range crossing the year', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'slash-separated-dd-mm-yyyy' });
for (const day of ['2025-12-22', '2025-12-29', '2026-01-05']) {
await createTimeEntryOnDateViaApi(ctx, {
date: new Date(`${day}T09:00:00Z`),
duration: '1h',
description: `Entry for ${day}`,
});
}
// The reporting page keeps its range in session storage, so seed a range spanning new year
// rather than driving the date picker.
await page.addInitScript(() => {
window.sessionStorage.setItem('reporting-start-date', '2025-12-15');
window.sessionStorage.setItem('reporting-end-date', '2026-01-15');
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const reportingView = page.getByTestId('reporting_view');
await expect(reportingView.getByText('22/12/2025 - 28/12/2025', { exact: true })).toBeVisible();
await expect(reportingView.getByText('29/12/2025 - 04/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText('05/01/2026 - 11/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
// ──────────────────────────────────────────────────
// Export Tests
// ──────────────────────────────────────────────────

View File

@@ -16,6 +16,7 @@ import {
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
createReportViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import {
goToReporting,
@@ -68,6 +69,42 @@ test('test that saving a report creates a shared report and its shareable link s
await expect(page.getByText('Total')).toBeVisible();
});
test('test that a shared report grouped by date shows date labels formatted by the organization setting', async ({
page,
ctx,
}) => {
const reportName = 'DateGroupReport ' + Math.floor(Math.random() * 10000);
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for date grouping',
duration: '1h',
});
await goToReporting(page);
// Switch the grouping to "Date"
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Verify row labels are formatted correctly
await page.goto(shareableLink);
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)).toBeVisible();
await expect(page.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
test('test that shared report with invalid secret shows no data', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
await expect(page.getByText('No time entries found').first()).toBeVisible();

View File

@@ -1,11 +1,11 @@
{
"Billing": {
"repository": "solidtime-io/extension-billing",
"ref": "v0.0.3"
"ref": "v0.0.5"
},
"Services": {
"repository": "solidtime-io/extension-services",
"ref": "v0.0.1"
"ref": "v0.0.2"
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",

View File

@@ -240,7 +240,8 @@ const groupedPieChartData = computed(() => {
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
@@ -255,11 +256,7 @@ const groupedPieChartData = computed(() => {
}
return {
value: entry.seconds,
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
) ?? '',
name: name ?? '',
color: color,
};
}) ?? []
@@ -269,18 +266,25 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(el.key, entry.grouped_type),
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type,
organization?.value?.date_format
),
};
}) ?? [],
};
@@ -421,9 +425,8 @@ const tableData = computed(() => {
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:key="entry.key ?? 'none'"
:currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:show-cost="showBillableRate"
:entry="entry"></ReportingRow>
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">

View File

@@ -11,6 +11,7 @@ type AggregatedGroupedData = GroupedData & {
};
type GroupedData = {
key: string | null;
seconds: number;
cost: number | null;
description: string | null | undefined;
@@ -72,7 +73,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
<ReportingRow
v-for="subEntry in entry.grouped_data"
:key="subEntry.description ?? 'none'"
:key="subEntry.key ?? 'none'"
:currency="props.currency"
:show-cost="showCost"
indent

View File

@@ -95,20 +95,24 @@ const tableData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type ?? null
entry.grouped_type ?? null,
organization?.value?.date_format
),
};
}) ?? [],
@@ -164,7 +168,7 @@ const showBillableRate = computed(() => {
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:key="entry.key ?? 'none'"
:currency="getOrganizationCurrencyString()"
:show-cost="showBillableRate"
:entry="entry"></ReportingRow>

View File

@@ -8,9 +8,13 @@ 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: '',
email: props.email,
password: '',
password_confirmation: '',
terms: false,

View File

@@ -116,25 +116,43 @@ const subGroup = computed(() => {
}
return 'project';
});
const { emptyPlaceholder } = useReportingStore();
const { emptyPlaceholder, getNameForReportingRowEntry } = useReportingStore();
/**
* The public report endpoint has no descriptor for time group types, so their labels are
* derived from the raw group key.
*/
function resolveLabel(
description: string | null | undefined,
key: string | null | undefined,
groupedType: string
) {
if (description !== null && description !== undefined) {
return description;
}
return (
getNameForReportingRowEntry(key ?? null, groupedType, reportDateFormat.value) ??
emptyPlaceholder[groupedType] ??
''
);
}
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
if (entry.description === null) {
const groupedType = aggregatedTableTimeEntries.value?.grouped_type ?? 'project';
const name = resolveLabel(entry.description, entry.key, groupedType);
if (name === emptyPlaceholder[groupedType]) {
return {
value: entry.seconds,
name:
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
] ?? '',
name: name,
color: '#CCCCCC',
};
}
return {
value: entry.seconds,
name: entry.description,
color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'),
name: name,
color: entry.color ?? getRandomColorWithSeed(name),
};
}) ?? []
);
@@ -143,21 +161,25 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description:
entry.description ??
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ??
'',
description: resolveLabel(
entry.description,
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
cost: el.cost,
description:
el.description ??
emptyPlaceholder[entry.grouped_type ?? 'project'] ??
'',
description: resolveLabel(
el.description,
el.key,
entry.grouped_type ?? 'project'
),
};
}) ?? [],
};
@@ -219,7 +241,7 @@ onMounted(async () => {
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:key="entry.key ?? 'none'"
:currency="reportCurrency"
:currency-format="reportCurrencyFormat"
:show-cost="true"

View File

@@ -198,7 +198,7 @@ const showResultModal = ref(false);
for="file-upload"
class="relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500">
<span v-if="files">{{ filenames }}</span>
<span v-else>Upload a Toggl/Clockify Export</span>
<span v-else>Upload the Export file</span>
<input
id="file-upload"
ref="importFile"

View File

@@ -25,25 +25,21 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<DialogPortal>
<DialogOverlay
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">
class="fixed top-0 left-0 z-50 w-screen h-screen [height:100dvh] 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" />
<div
class="absolute inset-0 overflow-y-auto overscroll-contain flex items-start justify-center px-2">
<DialogContent
v-bind="forwarded"
:class="
cn(
'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
)
">
<slot />
</DialogContent>
</div>
</DialogOverlay>
<div
:class="
cn(
'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'
)
">
<DialogContent
v-bind="forwarded"
:class="
cn(
'pointer-events-auto 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
)
">
<slot />
</DialogContent>
</div>
</DialogPortal>
</template>

View File

@@ -1,5 +1,10 @@
import { describe, expect, test } from 'vitest';
import { formatHumanReadableDuration, formatReportingDuration } from './time';
import {
formatHumanReadableDuration,
formatMonth,
formatReportingDuration,
formatWeekRange,
} from './time';
const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s
@@ -42,3 +47,27 @@ describe('formatReportingDuration', () => {
);
});
});
describe('formatWeekRange', () => {
test('renders the six days following the given first day of the week', () => {
expect(formatWeekRange('2026-07-27', 'slash-separated-dd-mm-yyyy')).toBe(
'27/07/2026 - 02/08/2026'
);
});
test('spans a month boundary', () => {
expect(formatWeekRange('2026-07-27')).toBe('27.7.2026 - 2.8.2026');
});
test('spans a year boundary', () => {
expect(formatWeekRange('2025-12-29', 'slash-separated-dd-mm-yyyy')).toBe(
'29/12/2025 - 04/01/2026'
);
});
});
describe('formatMonth', () => {
test('renders the name of the month that the key covers', () => {
expect(formatMonth('2001-02')).toBe('February 2001');
});
});

View File

@@ -236,6 +236,24 @@ export function formatWeek(date: string | null): string {
return 'Week ' + getDayJsInstance()(date).week();
}
/*
* Returns the range covered by the week starting on the given day.
* @param date - first day of a week, in the format of 'YYYY-MM-DD'
*/
export function formatWeekRange(date: string, format?: DateFormat): string {
const end = getDayJsInstance()(date).add(6, 'day').format('YYYY-MM-DD');
return `${formatDate(date, format)} - ${formatDate(end, format)}`;
}
/*
* Returns the month that the given key falls in. There is no `DateFormat` variant for a
* month, so this is not affected by the organization date format.
* @param date - a month, in the format of 'YYYY-MM'
*/
export function formatMonth(date: string): string {
return getDayJsInstance()(date).format('MMMM YYYY');
}
/*
* Returns a human readable date format.
* @param date - date in the format of 'YYYY-MM-DD'

View File

@@ -7,9 +7,20 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
import {
CalendarDaysIcon,
CalendarIcon,
DocumentTextIcon,
FolderIcon,
} from '@heroicons/vue/16/solid';
import { Coffee } from '@lucide/vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import {
type DateFormat,
formatDate,
formatMonth,
formatWeekRange,
} from '@/packages/ui/src/utils/time';
export type GroupingOption =
| 'project'
@@ -19,7 +30,11 @@ export type GroupingOption =
| 'client'
| 'description'
| 'tag'
| 'type';
| 'type'
| 'day'
| 'week'
| 'month'
| 'year';
export const useReportingStore = defineStore('reporting', () => {
// Cache query composables to avoid creating new subscriptions on every call
@@ -40,7 +55,11 @@ export const useReportingStore = defineStore('reporting', () => {
type: 'Work time',
} as Record<string, string>;
function getNameForReportingRowEntry(key: string | null, type: string | null) {
function getNameForReportingRowEntry(
key: string | null,
type: string | null,
dateFormat?: DateFormat
) {
if (type === null) {
return null;
}
@@ -76,6 +95,16 @@ export const useReportingStore = defineStore('reporting', () => {
if (type === 'type') {
return key === 'break' ? 'Break' : 'Work time';
}
if (type === 'day') {
return formatDate(key, dateFormat);
}
if (type === 'week') {
return formatWeekRange(key, dateFormat);
}
if (type === 'month') {
return formatMonth(key);
}
// A `year` key is already a bare year, so it falls through unchanged.
return key;
}
@@ -124,6 +153,16 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'tag',
icon: DocumentTextIcon,
},
{
label: 'Date',
value: 'day',
icon: CalendarIcon,
},
{
label: 'Week',
value: 'week',
icon: CalendarDaysIcon,
},
];
return {

View File

@@ -0,0 +1,49 @@
{{-- Self-contained on purpose: this page is rendered for a request on an
untrusted host, so it must not call url()/route()/asset(), which would
re-trigger Host validation and throw again. --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Untrusted host</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #1f2937;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.card {
max-width: 32rem;
margin: 1.5rem;
padding: 2rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
h1 { margin: 0 0 0.75rem; font-size: 1.25rem; }
p { margin: 0; line-height: 1.6; color: #4b5563; }
code {
padding: 0.1rem 0.35rem;
background: #f3f4f6;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="card">
<h1>Untrusted host</h1>
<p>
This hostname is not configured for this instance. Set
<code>APP_URL</code>, or add the host to <code>TRUSTED_HOSTS</code>.
</p>
</div>
</body>
</html>

View File

@@ -194,7 +194,7 @@
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
@endif
</span>
</td>
@@ -239,7 +239,7 @@
<span style="color: #a1a1aa;">
{{ $group->description() }}:
</span>
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
@endif
</h2>
@@ -278,7 +278,7 @@
@if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
@endif
</td>
<td>
@@ -318,7 +318,7 @@
series: [
{
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group, $localization): object {
$color = $data['color'];
if ($color === null) {
$color = $colorService->getRandomColor($data['key']);
@@ -328,7 +328,7 @@
}
return (object)[
'value' => $data['seconds'],
'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),
'name' => $data['description'] ?? $localization->formatTimeGroupKey($data['key'], $group) ?? 'No '.Str::lower($group->description()),
'color' => $color,
'itemStyle' => (object) [
'color' => $color,

View File

@@ -44,7 +44,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
</td>
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
@@ -53,7 +53,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
</td>
@endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
@@ -74,7 +74,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
</td>
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
@@ -83,7 +83,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
</td>
@endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\DateFormat;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TagMatchType;
@@ -16,6 +17,7 @@ use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
@@ -1641,6 +1643,212 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_date(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_pdf_renders_date_group_labels_in_organization_date_format(): void
{
// Arrange
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'debug' => 'true',
]));
// Assert
$this->assertResponseCode($response, 200);
$html = $response->json('html');
$this->assertIsString($html);
$this->assertStringContainsString('14/03/2024', $html);
$this->assertStringNotContainsString('2024-03-14', $html);
}
public function test_aggregate_export_csv_renders_date_group_labels_in_organization_date_format(): void
{
// Arrange
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$disk = Storage::disk(config('filesystems.private'));
$files = $disk->files('exports');
$this->assertCount(1, $files);
$csv = $disk->get($files[0]);
$this->assertIsString($csv);
$this->assertStringContainsString('14/03/2024', $csv);
$this->assertStringNotContainsString('2024-03-14', $csv);
}
/**
* @return array{0: Organization, 1: Member, 2: User}
*/
private function createWeeksSpanningNewYear(): array
{
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
// Note: the user factory pins the week start to Monday, so these land in predictable buckets
foreach (['2025-12-22', '2025-12-29', '2026-01-05'] as $day) {
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 3600)->create();
}
return [$data->organization, $data->member, $data->user];
}
public function test_aggregate_export_csv_labels_a_week_group_with_its_date_range(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$disk = Storage::disk(config('filesystems.private'));
$files = $disk->files('exports');
$this->assertCount(1, $files);
$csv = $disk->get($files[0]);
$this->assertIsString($csv);
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $csv);
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $csv);
$this->assertStringContainsString('05/01/2026 - 11/01/2026', $csv);
$this->assertStringNotContainsString('2025-12-29', $csv);
$this->assertStringNotContainsString('2025-12-22', $csv);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_week_spanning_new_year(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: a week 1 label carries a comma and reaches the echarts series in a <script> tag
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_pdf_labels_a_week_group_with_its_date_range(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
'debug' => 'true',
]));
// Assert
$this->assertResponseCode($response, 200);
$html = $response->json('html');
$this->assertIsString($html);
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $html);
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $html);
$this->assertStringNotContainsString('2025-12-29', $html);
}
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
{
// Arrange

View File

@@ -38,6 +38,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$response->assertSessionHas('registration_email', strtolower($invitation->email));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}
@@ -64,6 +65,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$response->assertSessionHas('registration_email', strtolower($invitation->email));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Enums;
use App\Enums\RegistrationMode;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(RegistrationMode::class)]
class RegistrationModeTest extends TestCase
{
/**
* @return array<string, array{mixed, RegistrationMode}>
*/
public static function configValueProvider(): array
{
return [
'boolean true' => [true, RegistrationMode::On],
'on' => ['on', RegistrationMode::On],
'true' => ['true', RegistrationMode::On],
'invite-only' => ['invite-only', RegistrationMode::InviteOnly],
'boolean false' => [false, RegistrationMode::Off],
'off' => ['off', RegistrationMode::Off],
'false' => ['false', RegistrationMode::Off],
'unsupported invite alias' => ['invite', RegistrationMode::Off],
'unknown' => ['unknown', RegistrationMode::Off],
];
}
#[DataProvider('configValueProvider')]
public function test_registration_mode_is_created_from_config_value(mixed $value, RegistrationMode $expected): void
{
$this->assertSame($expected, RegistrationMode::fromConfig($value));
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Middleware;
use App\Http\Middleware\TrustHosts;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Http\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;
#[CoversClass(TrustHosts::class)]
class TrustHostsTest extends TestCase
{
private const string CANONICAL = 'https://app.example.com';
protected function setUp(): void
{
parent::setUp();
config(['app.url' => self::CANONICAL]);
}
protected function tearDown(): void
{
Request::setTrustedHosts([]); // don't leak static state between tests
parent::tearDown();
}
/**
* The real middleware, with only the environment gate forced on (it
* self-exempts in the testing environment).
*/
private function middleware(): TrustHosts
{
return new class($this->app) extends TrustHosts
{
protected function shouldSpecifyTrustedHosts(): bool
{
return true;
}
};
}
private function accepts(Request $request): bool
{
$this->middleware()->handle($request, fn (Request $request): Response => new Response('passed'));
try {
dump($request->getHost());
return true;
} catch (\Throwable) {
return false;
}
}
public function test_canonical_host_is_accepted(): void
{
// Arrange
$request = Request::create(self::CANONICAL.'/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_subdomain_of_canonical_host_is_accepted(): void
{
// Arrange
$request = Request::create('https://team.app.example.com/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_declared_trusted_host_is_accepted(): void
{
// Arrange
config(['app.trusted_hosts' => ['box.tailnet.ts.net']]);
$request = Request::create('https://box.tailnet.ts.net/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_wildcard_trusted_host_matches_subdomains_only(): void
{
// Arrange
config(['app.trusted_hosts' => ['*.example.net']]);
$subdomainRequest = Request::create('https://foo.example.net/login');
$nestedSubdomainRequest = Request::create('https://a.b.example.net/login');
$apexRequest = Request::create('https://example.net/login');
$suffixInjectionRequest = Request::create('https://example.net.evil.com/login');
// Act
$subdomainAccepted = $this->accepts($subdomainRequest);
$nestedSubdomainAccepted = $this->accepts($nestedSubdomainRequest);
$apexAccepted = $this->accepts($apexRequest);
$suffixInjectionAccepted = $this->accepts($suffixInjectionRequest);
// Assert
$this->assertTrue($subdomainAccepted);
$this->assertTrue($nestedSubdomainAccepted);
$this->assertFalse($apexAccepted);
$this->assertFalse($suffixInjectionAccepted);
}
public function test_multiple_trusted_hosts_are_all_accepted(): void
{
// Arrange
config(['app.trusted_hosts' => [
'box.tailnet.ts.net',
'solidtime.internal',
'*.preview.example.com',
]]);
$tailnetRequest = Request::create('https://box.tailnet.ts.net/login');
$internalRequest = Request::create('https://solidtime.internal/login');
$previewRequest = Request::create('https://pr-42.preview.example.com/login');
$unlistedRequest = Request::create('https://evil.example.com/login');
// Act
$tailnetAccepted = $this->accepts($tailnetRequest);
$internalAccepted = $this->accepts($internalRequest);
$previewAccepted = $this->accepts($previewRequest);
$unlistedAccepted = $this->accepts($unlistedRequest);
// Assert
$this->assertTrue($tailnetAccepted);
$this->assertTrue($internalAccepted);
$this->assertTrue($previewAccepted);
$this->assertFalse($unlistedAccepted);
}
public function test_poisoned_host_is_rejected(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_poisoned_x_forwarded_host_is_rejected(): void
{
// Arrange
$request = Request::create(self::CANONICAL.'/login');
$request->headers->set('X-Forwarded-Host', 'evil.example.com');
$request->setTrustedProxies(
['0.0.0.0/0', '2000::/3'],
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_forwarded_host_from_trusted_proxy_is_accepted(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
$request->headers->set('X-Forwarded-Host', 'app.example.com');
$request->setTrustedProxies(
['0.0.0.0/0', '2000::/3'],
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_forwarded_host_from_non_trusted_proxy_is_rejected_if_host_is_allowed(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
$request->headers->set('X-Forwarded-Host', 'app.example.com');
$request->setTrustedProxies(
['1.2.3.4/32'], // Not a trusted proxy
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_health_check_endpoint_bypasses_host_validation(): void
{
// Arrange
$internalIpRequest = Request::create('https://0.0.0.0/health-check/up');
$localhostRequest = Request::create('http://localhost/health-check/up');
// Act
$internalIpAccepted = $this->accepts($internalIpRequest);
$localhostAccepted = $this->accepts($localhostRequest);
// Assert
$this->assertTrue($internalIpAccepted);
$this->assertTrue($localhostAccepted);
}
public function test_health_check_endpoint_clears_state_before_other_middleware_reads_the_host(): void
{
// Arrange
Request::setTrustedHosts(['^app\.example\.com$']);
// Act
$response = $this->get(self::CANONICAL.'/health-check/up', ['Host' => '0.0.0.0']);
// Assert
$response->assertSuccessful()
->assertExactJson(['success' => true]);
}
public function test_untrusted_host_renders_a_helpful_error(): void
{
// Arrange
$handler = app(ExceptionHandler::class);
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
$request = Request::create('https://evil.example.com/login');
// Act
$response = $handler->render($request, $exception);
// Assert
$this->assertSame(400, $response->getStatusCode());
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
}
public function test_untrusted_host_returns_json_for_api_clients(): void
{
// Arrange
$handler = app(ExceptionHandler::class);
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
$request = Request::create('https://evil.example.com/api/v1/users');
$request->headers->set('Accept', 'application/json');
// Act
$response = $handler->render($request, $exception);
// Assert
$this->assertSame(400, $response->getStatusCode());
$this->assertJson((string) $response->getContent());
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
}
}

View File

@@ -8,6 +8,7 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Service\LocalizationService;
use Brick\Money\Currency;
@@ -303,4 +304,87 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Assert
$this->assertSame('14:09', $formatted);
}
public function test_format_time_group_key_formats_a_day_key_with_the_date_format(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Day);
// Assert
$this->assertSame('03/02/2001', $formatted);
}
public function test_format_time_group_key_returns_null_for_a_null_key(): void
{
// Act
$formatted = $this->localizationService->formatTimeGroupKey(null, TimeEntryAggregationType::Day);
// Assert
$this->assertNull($formatted);
}
public function test_format_time_group_key_formats_a_week_key_as_the_range_it_covers(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2026-07-27', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('27/07/2026 - 02/08/2026', $formatted);
}
public function test_format_time_group_key_formats_a_week_range_from_the_weekday_of_its_own_key(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$sundayStart = $this->localizationService->formatTimeGroupKey('2026-07-26', TimeEntryAggregationType::Week);
$mondayStart = $this->localizationService->formatTimeGroupKey('2026-07-20', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('26/07/2026 - 01/08/2026', $sundayStart);
$this->assertSame('20/07/2026 - 26/07/2026', $mondayStart);
}
public function test_format_time_group_key_formats_a_week_range_spanning_new_year(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2025-12-29', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('29/12/2025 - 04/01/2026', $formatted);
}
public function test_format_time_group_key_formats_a_month_key_and_does_not_change_year_and_entity_group_types(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act & Assert
$this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('2001', $this->localizationService->formatTimeGroupKey('2001', TimeEntryAggregationType::Year));
$this->assertSame('some-uuid', $this->localizationService->formatTimeGroupKey('some-uuid', TimeEntryAggregationType::Project));
}
public function test_format_time_group_key_formats_a_month_key_independently_of_the_current_day_of_month(): void
{
// Arrange
// Note: the current day of the month must not leak into the parsed month. The 31st does
// not exist in April, so a leaked day overflows the date into the following month.
$this->travelTo(Carbon::create(2026, 8, 31, 12, 0, 0, 'UTC'));
// Act & Assert
$this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('April 2026', $this->localizationService->formatTimeGroupKey('2026-04', TimeEntryAggregationType::Month));
$this->assertSame('December 2026', $this->localizationService->formatTimeGroupKey('2026-12', TimeEntryAggregationType::Month));
}
}

View File

@@ -1302,4 +1302,111 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregate_time_entries_by_week_keys_each_group_by_the_first_day_of_the_week(): void
{
// Arrange
// 2026-07-20 is a Monday, 2026-07-26 is the Sunday of that week.
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame([
'seconds' => 30,
'cost' => 0,
'grouped_type' => 'week',
'grouped_data' => [
[
'key' => '2026-07-20',
'seconds' => 20,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => '2026-07-27',
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
public function test_aggregate_time_entries_by_week_aligns_the_group_key_to_the_organization_week_start(): void
{
// Arrange
// With a Sunday week start the Sunday entry belongs to the following week instead.
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Sunday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame(['2026-07-19', '2026-07-26'], array_column($result['grouped_data'], 'key'));
$this->assertSame([10, 20], array_column($result['grouped_data'], 'seconds'));
}
public function test_aggregate_time_entries_by_week_keys_a_week_spanning_new_year_by_its_start_in_the_earlier_year(): void
{
// Arrange
// Note: 2025-12-29 is the Monday of the week containing 1 January 2026
foreach (['2025-12-29', '2025-12-31', '2026-01-01', '2026-01-05'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame(['2025-12-29', '2026-01-05'], array_column($result['grouped_data'], 'key'));
// Three of the four entries fall in the week that straddles new year.
$this->assertSame([30, 10], array_column($result['grouped_data'], 'seconds'));
}
}