Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
dbc927b6a9 Add core support for extendable authentication
add support for passwordless user creation; add filament loading support
for new laravel modules namespacing;
add support for pluggable password reset and login rules
2026-06-23 18:32:54 +02:00
292 changed files with 1907 additions and 13256 deletions

View File

@@ -1,54 +0,0 @@
.git
**/.git
.gitmodules
**/.gitmodules
.github
.DS_Store
.fleet
.idea
.vscode
*.log
npm-debug.log
yarn-error.log
k8s
docs
e2e
tests
docker-compose.yml
docker/local
.phpunit.cache
.phpunit.result.cache
coverage
test-results
playwright-report
blob-report
playwright/.cache
openapi.json
playwright
playwright.config.ts
vitest.config.ts
phpunit.xml
phpstan.neon
pint.json
eslint.config.mjs
tsconfig.json
jsconfig.json
postcss.config.js
tailwind.config.js
node_modules
extensions/*/node_modules
Homestead.json
Homestead.yaml
auth.json
.env.backup
.rnd
_ide_helper.php
.phpstorm.meta.php
storage/logs/*
storage/*.key

27
.github/VOUCHED.td vendored
View File

@@ -1,27 +0,0 @@
# Vouched contributors for solidtime.
#
# One handle per line, without the leading @, sorted alphabetically.
# Prefix a handle with - to denounce them, optionally followed by a reason.
# Format reference: https://github.com/mitchellh/vouch
#
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
# discussion and the vouch workflows will update this file.
#
# Collaborators with write access and bots are always allowed and do not need
# an entry here.
#
# Seeded 2026-07-25 from the authors of every merged pull request.
agross
akolenda
bufferhead-code
candideu
kasparrosin
korridor
nikbucher tasks table sorting
onatcer
shrootbuck
smilebeda
thespyder
utlark

View File

@@ -8,8 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
@@ -37,7 +35,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -93,23 +91,14 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,8 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-private.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
permissions:
@@ -24,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -70,27 +68,14 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
repository: solidtime-io/extension-billing
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
@@ -108,10 +93,9 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
repository: solidtime-io/extension-services
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
@@ -127,10 +111,9 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,7 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-public.yml'
- '.dockerignore'
- 'docker/prod/**'
workflow_dispatch:
@@ -37,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -93,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,10 +9,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -13,10 +13,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -1,75 +0,0 @@
name: Vouch (check PR)
on:
pull_request_target:
types: [opened, reopened, synchronize]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
if: >-
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/recheck'))
steps:
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
- name: "Measure diff size"
id: size
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
# Changes to these files do not count towards the 50-line limit.
# One extended regex per line, matched against the whole repo-relative
# path, so use a leading .* to match a file in any directory.
IGNORED: |
package-lock\.json
composer\.lock
tests/.*
e2e/.*
.*\.(test|spec)\.(ts|js|vue)
run: |
set -euo pipefail
# An empty list yields "^()$", which matches no filename. grep exits
# 1 on an empty list, so swallow that rather than fail the step.
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
ignored="^($(join <<<"$IGNORED"))$"
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
awk -F'\t' -v ignored="$ignored" '
$1 ~ ignored { next }
{ n += $2 }
END { print n+0 }')
echo "total=$total" >> "$GITHUB_OUTPUT"
echo "Countable diff size: $total line(s)"
- name: "Small patch (denounced users still blocked)"
if: fromJSON(steps.size.outputs.total) <= 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
require-vouch: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Full vouch required"
if: fromJSON(steps.size.outputs.total) > 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,33 +0,0 @@
name: Vouch (manage by discussion)
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
on:
discussion_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
discussions: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,35 +0,0 @@
name: Vouch (manage by issue)
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -26,10 +26,9 @@ yarn-error.log
/blob-report/
/playwright/.cache/
/coverage
/extensions/*
/extensions
!/extensions/.gitkeep
!/extensions/extensions_autoload.php
!/extensions/manifest.json
/auth.json
/modules_statuses.json
/k8s

View File

@@ -12,22 +12,6 @@ In order to keep the issues of the repository clean we decided to only use them
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Vouched contributors
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
Your pull request is not affected if any of the following applies:
- You have write access to this repository.
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
### Contributor License Agreement
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.

View File

@@ -39,8 +39,6 @@ Please open an issue or start a discussion and wait for approval before submitti
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -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();

View File

@@ -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';
}

View File

@@ -21,7 +21,6 @@ enum TimeEntryAggregationType: string
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
case Type = 'type';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryType: string
{
use LaravelEnumHelper;
case Work = 'work';
case Break = 'break';
}

View File

@@ -60,6 +60,7 @@ abstract class ApiException extends Exception
*/
public function report(): bool
{
// TODO: temporary activated
return false;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class CanNotRemoveOwnerFromOrganization extends ApiException
{
public const string KEY = 'can_not_remove_owner_from_organization';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
}

View File

@@ -19,16 +19,6 @@ class EntityStillInUseApiException extends ApiException
public const string KEY = 'entity_still_in_use';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
/**
* Get the translated message for the exception.
*/

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{
public const string KEY = 'feature_is_not_available_in_free_plan';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class InactiveUserCanNotBeUsedApiException extends ApiException
{
public const string KEY = 'inactive_user_can_not_be_used';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{
public const string KEY = 'invitation_for_the_email_already_exists';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException
{
public const string KEY = 'only_owner_can_change_ownership';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException
{
public const string KEY = 'organization_needs_at_least_one_owner';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException
{
public const string KEY = 'overlapping_time_entry';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException
{
public const string KEY = 'time_entry_can_not_be_restarted';
public function report(): bool
{
return true;
}
}

View File

@@ -7,14 +7,4 @@ namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException
{
public const string KEY = 'time_entry_still_running';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_organization';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfProjectApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_project';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException
{
public const string KEY = 'user_not_placeholder';
public function report(): bool
{
return true;
}
}

View File

@@ -7,9 +7,4 @@ namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{
public const string KEY = 'user_resend_email_verification_no_pending_email';
public function report(): bool
{
return true;
}
}

View File

@@ -6,11 +6,7 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable;
class Handler extends ExceptionHandler
@@ -34,33 +30,6 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void {
//
});
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
&& $e->getErrorType() === 'access_denied'
&& $e->getHttpStatusCode() === 401);
// 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

@@ -66,7 +66,7 @@ class UserResource extends Resource
->ignore($record?->getKey()),
])
->rule([
'email:rfc,strict',
'email',
])
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')

View File

@@ -43,8 +43,7 @@ class ClientController extends Controller
$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc')
->orderBy('id');
->orderBy('created_at', 'desc');
if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);

View File

@@ -28,9 +28,7 @@ class ExportController extends Controller
$filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
]);
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
return new JsonResponse([
'success' => true,

View File

@@ -42,7 +42,6 @@ class InvitationController extends Controller
$invitations = $organization->organizationInvitations()
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations);

View File

@@ -61,7 +61,6 @@ class MemberController extends Controller
->whereBelongsTo($organization, 'organization')
->with(['user'])
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members);

View File

@@ -78,9 +78,6 @@ class OrganizationController extends Controller
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
if ($request->getBreaksEnabled() !== null) {
$organization->breaks_enabled = $request->getBreaksEnabled();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -62,7 +62,6 @@ class ProjectController extends Controller
$projects = $projectsQuery
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;

View File

@@ -49,7 +49,6 @@ class ProjectMemberController extends Controller
$projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers);

View File

@@ -57,10 +57,9 @@ class ReportController extends Controller
$filter->addEnd($properties->end);
$filter->addActive($properties->active);
$filter->addBillable($properties->billable);
$filter->addType($properties->timeEntryType);
$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();

View File

@@ -47,7 +47,6 @@ class ReportController extends Controller
$reports = Report::query()
->orderBy('created_at', 'desc')
->orderBy('id')
->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default'));
@@ -97,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;
@@ -112,7 +110,6 @@ class ReportController extends Controller
$properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$properties->timeEntryType = $request->getPropertyTimeEntryType();
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();

View File

@@ -42,7 +42,6 @@ class TagController extends Controller
$tags = Tag::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return new TagCollection($tags);

View File

@@ -84,7 +84,6 @@ class TaskController extends Controller
$tasks = $query
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks);

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
@@ -68,7 +67,7 @@ class TimeEntryController extends Controller
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('member_id', $member->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
@@ -108,8 +107,8 @@ class TimeEntryController extends Controller
/**
* Get time entries in organization
*
* If you only need time entries for a specific user, you can filter by `member_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
*
* @return TimeEntryCollection<TimeEntryResource>
*
@@ -119,17 +118,16 @@ class TimeEntryController extends Controller
*/
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count();
@@ -160,7 +158,7 @@ class TimeEntryController extends Controller
if ($timeEntries->count() === 0) {
Log::warning('User has has more than '.$limit.' time entries on one date', [
'date' => $lastDate->toDateString(),
'member_id' => $request->input('member_id'),
'user_id' => $request->input('user_id'),
'auth_user_id' => Auth::id(),
'limit' => $limit,
]);
@@ -196,8 +194,7 @@ class TimeEntryController extends Controller
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('time_entries.start', 'desc')
->orderBy('time_entries.id');
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
@@ -206,11 +203,10 @@ 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'));
$filter->addTypeFilter($request->input('type'));
return $filter->get();
}
@@ -224,10 +220,9 @@ class TimeEntryController extends Controller
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -244,7 +239,7 @@ class TimeEntryController extends Controller
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([
'task',
'client',
@@ -267,7 +262,7 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
null,
@@ -336,9 +331,7 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
@@ -376,10 +369,9 @@ class TimeEntryController extends Controller
*/
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -390,7 +382,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -426,10 +418,9 @@ class TimeEntryController extends Controller
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -445,7 +436,7 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -537,7 +528,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
@@ -549,9 +540,7 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
@@ -570,11 +559,10 @@ 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'));
$filter->addTypeFilter($request->input('type'));
return $filter->get();
}
@@ -591,7 +579,7 @@ class TimeEntryController extends Controller
{
/** @var Member $member */
$member = Member::query()->findOrFail($request->input('member_id'));
if ($member->getKey() === $this->member($organization)->getKey()) {
if ($member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:create:own');
} else {
$this->checkPermission($organization, 'time-entries:create:all');
@@ -638,10 +626,9 @@ class TimeEntryController extends Controller
*/
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{
$member = $this->member($organization);
/** @var Member|null $newMember */
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
@@ -673,10 +660,6 @@ class TimeEntryController extends Controller
}
$timeEntry->fill($request->validated());
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user()->associate($newMember->user);
}
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
@@ -706,7 +689,6 @@ class TimeEntryController extends Controller
*/
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
{
$member = $this->member($organization);
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
@@ -731,9 +713,6 @@ class TimeEntryController extends Controller
throw new AuthorizationException;
}
/** @var Member|null $newMember */
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
$project = null;
$client = null;
$overwriteClient = false;
@@ -760,33 +739,16 @@ class TimeEntryController extends Controller
continue;
}
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;
}
// Changing time entries to Break entries is only allowed when breaks are enabled in the org settings
$resultingType = isset($changes['type']) ? TimeEntryType::from($changes['type']) : $timeEntry->type;
if ($resultingType === TimeEntryType::Break && $timeEntry->type !== TimeEntryType::Break && ! $organization->breaks_enabled) {
$error->push($id);
continue;
}
// Break entries can not be billable, have tags or belong to a project/task (see TimeEntry::booted)
if ($resultingType === TimeEntryType::Break && ($project !== null || $task !== null || $request->boolean('changes.billable') || count($changes['tags'] ?? []) > 0)) {
$error->push($id);
continue;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$timeEntry->fill($changes);
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user_id = $newMember->user_id;
}
// If project is changed, but task is not, we remove the old task from the time entry
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate();
@@ -827,8 +789,7 @@ class TimeEntryController extends Controller
*/
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{
$member = $this->member($organization);
if ($timeEntry->member_id === $member->getKey()) {
if ($timeEntry->member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
@@ -885,7 +846,7 @@ class TimeEntryController extends Controller
continue;
}
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;

View File

@@ -15,7 +15,6 @@ 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;
@@ -48,7 +47,6 @@ class Kernel extends HttpKernel
*/
protected $middleware = [
ForceHttps::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,

View File

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

@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
return [
'email' => [
'required',
'email:rfc,strict',
'email',
],
'role' => [
'required',

View File

@@ -51,9 +51,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'prevent_overlapping_time_entries' => [
'boolean',
],
'breaks_enabled' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -128,9 +125,4 @@ class OrganizationUpdateRequest extends BaseFormRequest
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
public function getBreaksEnabled(): ?bool
{
return $this->has('breaks_enabled') ? $this->boolean('breaks_enabled') : null;
}
}

View File

@@ -4,11 +4,9 @@ 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;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
@@ -126,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [
'nullable',
'array',
@@ -178,12 +171,6 @@ class ReportStoreRequest extends BaseFormRequest
'numeric',
'integer',
],
// Filter by time entry type
'properties.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
@@ -247,15 +234,6 @@ class ReportStoreRequest extends BaseFormRequest
return null;
}
public function getPropertyTimeEntryType(): ?TimeEntryType
{
if (! $this->has('properties.time_entry_type') || $this->input('properties.time_entry_type') === null) {
return null;
}
return TimeEntryType::from($this->input('properties.time_entry_type'));
}
public function getPropertyGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.group'));
@@ -271,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) {

View File

@@ -5,11 +5,9 @@ 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;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -141,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',
@@ -184,11 +178,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
@@ -257,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) {

View File

@@ -4,10 +4,8 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -127,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',
@@ -170,11 +164,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
@@ -219,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) {

View File

@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -112,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',
@@ -156,11 +150,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
@@ -226,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) {

View File

@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -105,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',
@@ -149,11 +143,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
@@ -201,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) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
@@ -15,7 +14,6 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -26,7 +24,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
@@ -44,7 +42,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
'prohibited_if:type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -63,7 +60,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'task_id' => [
'nullable',
'string',
'prohibited_if:type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -89,16 +85,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'billable' => [
'required',
'boolean',
'declined_if:type,break',
],
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryType::Break->value && ! $this->organization->breaks_enabled) {
$fail('Breaks are disabled for this organization.');
}
},
],
// Description of time entry
'description' => [
@@ -110,7 +96,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'tags' => [
'nullable',
'array',
'prohibited_if:type,break',
],
'tags.*' => [
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
@@ -15,7 +14,6 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -26,7 +24,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
@@ -56,7 +54,6 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -75,7 +72,6 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.task_id' => [
'nullable',
'string',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -88,13 +84,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
],
// Whether time entry is billable
'changes.billable' => [
'sometimes',
'boolean',
'declined_if:changes.type,break',
],
// Type of the time entry (work time or a break)
'changes.type' => [
Rule::enum(TimeEntryType::class),
],
// Description of time entry
'changes.description' => [
@@ -106,7 +96,6 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.tags' => [
'nullable',
'array',
'prohibited_if:changes.type,break',
],
'changes.tags.*' => [
'string',

View File

@@ -4,21 +4,16 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ConditionalRules;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\ProhibitedIf;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -29,19 +24,10 @@ class TimeEntryUpdateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule|ProhibitedIf|ConditionalRules>>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
// Break restrictions need to apply based on the type the entry will have after the
// update, not only when the payload itself contains type=break.
$timeEntry = $this->route('timeEntry');
$timeEntry = $timeEntry instanceof TimeEntry ? $timeEntry : null;
$resultingType = $this->has('type')
? TimeEntryType::tryFrom((string) $this->input('type'))
: $timeEntry?->type;
$isBreak = $resultingType === TimeEntryType::Break;
return [
// ID of the organization member that the time entry should belong to
'member_id' => [
@@ -56,7 +42,6 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -75,7 +60,6 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'task_id' => [
'nullable',
'string',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -98,22 +82,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
],
// Whether time entry is billable
'billable' => [
'sometimes',
'boolean',
Rule::when($isBreak, ['declined']),
],
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail) use ($timeEntry): void {
// While breaks are disabled, entries that already are breaks may stay
// breaks, but converting a work entry to a break is not allowed.
if ($value === TimeEntryType::Break->value
&& ! $this->organization->breaks_enabled
&& $timeEntry?->type !== TimeEntryType::Break) {
$fail('Breaks are disabled for this organization.');
}
},
],
// Description of time entry
'description' => [
@@ -125,7 +94,6 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'tags' => [
'nullable',
'array',
Rule::prohibitedIf($isBreak),
],
'tags.*' => [
'string',

View File

@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
'max:255',
],
'email' => [
'email:rfc,strict',
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */

View File

@@ -57,8 +57,6 @@ class OrganizationResource extends BaseResource
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -50,16 +50,12 @@ class DetailedReportResource extends BaseResource
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable,
/** @var string|null $time_entry_type Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'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 */

View File

@@ -47,8 +47,6 @@ class TimeEntryResource extends BaseResource
'tags' => $this->resource->tags ?? [],
/** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable,
/** @var string $type Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value,
];
}
}

View File

@@ -20,11 +20,6 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Project $project;
/**

View File

@@ -20,11 +20,6 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Task $task;
/**

View File

@@ -34,7 +34,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries
* @property bool $breaks_enabled
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@@ -71,7 +70,6 @@ class Organization extends Model implements AuditableContract
'employees_can_see_billable_rates' => 'boolean',
'employees_can_manage_tasks' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'breaks_enabled' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\TimeEntryType;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use App\Service\BillableRateService;
@@ -29,7 +28,6 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable
* @property TimeEntryType $type
* @property array<string> $tags
* @property string $user_id
* @property string $member_id
@@ -73,20 +71,12 @@ class TimeEntry extends Model implements AuditableContract
'start' => 'datetime',
'end' => 'datetime',
'billable' => 'bool',
'type' => TimeEntryType::class,
'tags' => 'array',
'billable_rate' => 'int',
'is_imported' => 'bool',
'still_active_email_sent_at' => 'datetime',
];
/**
* @var array<string, string>
*/
protected $attributes = [
'type' => 'work',
];
public const array SELECT_COLUMNS = [
'id',
'description',
@@ -94,7 +84,6 @@ class TimeEntry extends Model implements AuditableContract
'end',
'billable_rate',
'billable',
'type',
'user_id',
'organization_id',
'project_id',
@@ -128,21 +117,6 @@ class TimeEntry extends Model implements AuditableContract
'billable_rate',
];
protected static function booted(): void
{
// Break entries can never be billable, have tags or belong to a project/task.
static::saving(function (TimeEntry $timeEntry): void {
if ($timeEntry->type === TimeEntryType::Break) {
$timeEntry->billable = false;
$timeEntry->billable_rate = null;
$timeEntry->project_id = null;
$timeEntry->task_id = null;
$timeEntry->client_id = null;
$timeEntry->tags = [];
}
});
}
public function getBillableRateComputed(): ?int
{
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
@@ -199,16 +173,6 @@ class TimeEntry extends Model implements AuditableContract
$builder->whereJsonContains('tags', $tag->getKey());
}
/**
* Only work entries — breaks do not count toward tracked/billable time.
*
* @param Builder<TimeEntry> $builder
*/
public function scopeWorkTime(Builder $builder): void
{
$builder->where('type', '=', TimeEntryType::Work);
}
/**
* @return BelongsTo<User, $this>
*/

View File

@@ -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')) {

View File

@@ -20,7 +20,6 @@ use App\Service\BillingContract;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore;
use DateTimeInterface;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
@@ -30,13 +29,8 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AppServiceProvider extends ServiceProvider
{
@@ -104,27 +98,6 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class);
// Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
// so mirror it through the signed query parameters of the storage route.
$privateDisk = config('filesystems.private');
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
$disk = Storage::disk($privateDisk);
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
});
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
$parameters = array_filter([
'path' => $path,
'disposition' => isset($options['ResponseContentDisposition'])
? Str::before($options['ResponseContentDisposition'], ';')
: null,
]);
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
});
}
// Routing
Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class);

View File

@@ -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), '/');
}
}

View File

@@ -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;
}

View File

@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
}
return $request->user()
? 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());
? Limit::perMinute(200)->by($request->user()->id)
: Limit::perMinute(60)->by($request->ip());
});
$this->routes(function (): void {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\Project;
@@ -155,7 +154,6 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -197,7 +195,6 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -225,8 +222,7 @@ class DashboardService
$query = TimeEntry::query()
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime();
->where('organization_id', '=', $organization->getKey());
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */
@@ -294,7 +290,6 @@ class DashboardService
->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy('project_id');
$query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start);
@@ -438,8 +433,7 @@ class DashboardService
JOIN time_entries ON time_entries.start < time_ranges."end"
AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start
WHERE time_entries.user_id = :user_id and
time_entries.organization_id = :organization_id and
time_entries.type = :work_type
time_entries.organization_id = :organization_id
GROUP BY time_ranges.start
ORDER BY time_ranges.start
', [
@@ -448,7 +442,6 @@ class DashboardService
'user_id' => $user->getKey(),
'organization_id' => $organization->getKey(),
'now' => Carbon::now()->toDateTimeString(),
'work_type' => TimeEntryType::Work->value,
]))->pluck('aggregate', 'start');
$response = [];

View File

@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Database\Eloquent\Castable;
@@ -58,8 +56,6 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
@@ -69,8 +65,6 @@ class ReportPropertiesDto implements Castable
public ?int $roundingMinutes = null;
public ?TimeEntryType $timeEntryType = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
@@ -121,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);
@@ -132,12 +125,6 @@ class ReportPropertiesDto implements Castable
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
// Note: timeEntryType was added later, reports persisted before that are missing the value and default to "work"
if (property_exists($data, 'timeEntryType')) {
$dto->timeEntryType = $data->timeEntryType !== null ? TimeEntryType::from($data->timeEntryType) : null;
} else {
$dto->timeEntryType = TimeEntryType::Work;
}
return $dto;
}
@@ -157,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,
@@ -166,7 +152,6 @@ class ReportPropertiesDto implements Castable
'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
'timeEntryType' => $value->timeEntryType?->value,
];
$jsonString = json_encode($data);
@@ -231,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
*/

View File

@@ -107,7 +107,6 @@ class ExportService
'end',
'billable_rate',
'billable',
'type',
'member_id',
'user_id',
'organization_id',
@@ -132,7 +131,6 @@ class ExportService
$timeEntry->end?->toIso8601ZuluString() ?? '',
$timeEntry->billable_rate ?? '',
$timeEntry->billable ? 'true' : 'false',
$timeEntry->type->value,
$timeEntry->member_id,
$timeEntry->user_id,
$timeEntry->organization_id,

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
@@ -25,12 +24,10 @@ class ClockifyProjectsImporter extends DefaultImporter
$header = $reader->getHeader();
$this->validateHeader($header);
$billableRateKey = $this->getBillableRateKey($header);
$tasksKey = $this->getTasksKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
// Newer Clockify exports no longer contain a "Client" column.
if (($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -46,13 +43,12 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
]);
}
if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]);
if ($record['Task'] !== '') {
$tasks = explode(', ', $record['Task']);
foreach ($tasks as $task) {
$this->taskImportHelper->getKey([
'name' => $task,
@@ -81,36 +77,17 @@ class ClockifyProjectsImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Status',
'Visibility',
'Billability',
'Task',
];
foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
if ($this->getTasksKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Tasks');
}
}
/**
* Clockify names the tasks column differently depending on the export
* version: "Task" (older), "Tasks" (newer) or "Activities".
*
* @param array<string> $header
*/
private function getTasksKey(array $header): ?string
{
foreach (['Tasks', 'Task', 'Activities'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
/**

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
@@ -55,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
@@ -72,12 +70,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
// Clockify allows a project/task/client/tags/billable on breaks, but those are
// meaningless for non-work time. Detect breaks up front and skip creating any of
// that so a break can't spawn an orphan project/tag or inflate the import counts.
$isBreak = isset($record['Type']) && strtolower($record['Type']) === 'break';
$clientId = null;
if (! $isBreak && ($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -86,7 +80,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$projectId = null;
$project = null;
$projectMember = null;
if (! $isBreak && $record['Project'] !== '') {
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
@@ -102,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$taskId = null;
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey],
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
@@ -122,18 +116,11 @@ 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');
}
if ($isBreak) {
// Breaks can not be billable or belong to a project/task (already skipped above)
$timeEntry->type = TimeEntryType::Break;
$timeEntry->billable = false;
}
$timeEntry->tags = $isBreak ? [] : $this->getTags($record['Tags']);
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
// Start
@@ -225,11 +212,14 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Description',
'Task',
'User',
'Group',
'Email',
'Tags',
'Billable',
'Start Date',
'Start Time',
'End Date',
@@ -240,26 +230,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
#[\Override]

View File

@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
}, validate: [
'email' => [
'required',
'email:rfc,strict',
'email',
'max:255',
],
]);

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
@@ -256,14 +255,6 @@ class SolidtimeImporter extends DefaultImporter
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
// The type column does not exist in old exports
if (($timeEntryRow['type'] ?? '') !== '') {
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
if ($type === null) {
throw new ImportException('Invalid type value');
}
$timeEntry->type = $type;
}
$timeEntry->tags = $this->getTags($timeEntryRow['tags']);
$timeEntry->is_imported = true;

View File

@@ -123,7 +123,6 @@ class TogglDataImporter extends DefaultImporter
}
foreach ($projects as $project) {
$projectExternalId = $this->guardExternalIdentifier($project->id);
$clientId = null;
if ($project->client_id !== null) {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
@@ -147,16 +146,16 @@ class TogglDataImporter extends DefaultImporter
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
], (string) $project->id);
if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP');
if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) {
throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP');
}
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'));
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json'));
if ($projectMembersFileContent === false) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" can not be opened');
throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened');
}
$projectMembers = json_decode($projectMembersFileContent);
if ($projectMembers === null) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" is empty');
throw new ImportException('File "projects_users/'.$project->id.'.json" is empty');
}
foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
@@ -171,7 +170,6 @@ class TogglDataImporter extends DefaultImporter
}
$projectIds = $this->projectImportHelper->getExternalIds();
foreach ($projectIds as $projectIdExternal) {
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
continue;
}
@@ -211,30 +209,6 @@ class TogglDataImporter extends DefaultImporter
}
}
/**
* Ensure an externally-sourced identifier can be safely used inside a
* filesystem path. The identifiers originate from the untrusted uploaded
* ZIP, and Spatie's TemporaryDirectory::path() auto-creates any missing
* parent directory of the resolved path, so an unfiltered "../" sequence
* would escape the import sandbox and create/probe arbitrary paths on the
* host (CWE-22). Toggl identifiers are numeric, so restricting them to a
* conservative allow-list rejects traversal without affecting real data.
*
* @throws ImportException
*/
private function guardExternalIdentifier(mixed $id): string
{
if (! is_string($id) && ! is_int($id)) {
throw new ImportException('Invalid identifier in import data');
}
$id = (string) $id;
if (preg_match('/^[A-Za-z0-9_-]+$/', $id) !== 1) {
throw new ImportException('Invalid identifier in import data');
}
return $id;
}
#[Override]
public function getName(): string
{

View File

@@ -23,10 +23,6 @@ class InvitationService
*/
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
{
// Normalize the email so it matches how user emails are stored (see UserService::createUser),
// otherwise a mixed-case invite silently fails to link on registration.
$email = strtolower($email);
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
@@ -59,7 +55,7 @@ class InvitationService
$organizations = new Collection;
$invitations = OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($user->email)])
->where('email', $user->email)
->whereNotNull('accepted_at')
->get();

View File

@@ -8,14 +8,12 @@ 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
{
@@ -154,38 +152,6 @@ 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

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry;
use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder;
@@ -26,7 +25,6 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration',
'Duration (decimal)',
'Billable',
'Break',
'Tags',
];
@@ -60,7 +58,6 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,
'Duration (decimal)' => $duration?->totalHours,
'Billable' => $model->billable ? 'Yes' : 'No',
'Break' => $model->type === TimeEntryType::Break ? 'Yes' : 'No',
'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
];
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry;
use App\Service\LocalizationService;
use Illuminate\Database\Eloquent\Builder;
@@ -107,7 +106,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
'Duration',
'Duration (decimal)',
'Billable',
'Break',
'Tags',
];
}
@@ -132,7 +130,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
@@ -147,7 +144,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} else {

View File

@@ -6,7 +6,6 @@ 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;
@@ -49,8 +48,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private bool $showBillableRate;
private LocalizationService $localization;
/**
* @param array{
* grouped_type: string|null,
@@ -71,7 +68,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, LocalizationService $localization)
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
@@ -79,7 +76,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
$this->group = $group;
$this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
$this->localization = $localization;
}
public function view(): View
@@ -91,7 +87,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
'localization' => $this->localization,
]);
}

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