Compare commits

..

163 Commits

Author SHA1 Message Date
blacksmith-sh[bot]
7ad37d35d8 Migrate workflows to Blacksmith 2026-08-06 16:36:35 +00:00
Constantin Graf
de13c07855 Fixed return type in TrustHosts 2026-08-06 17:49:52 +02:00
Constantin Graf
29a2e994cd Fixed phpstan error in TrustHosts middleware 2026-08-06 17:06:34 +02:00
Constantin Graf
f6d886b218 Refactored TrustHostsTest; Added return types to TrustHosts 2026-08-06 17:01:46 +02:00
Gregor Vostrak
80d98b30a1 add custom error handling for host mismatch; ensure TrustHosts runs
before TrustProxies
2026-08-06 17:01:46 +02:00
Gregor Vostrak
32f2f1431b add TrustHosts middleware with exemption for healthchecks 2026-08-06 17:01:46 +02:00
Constantin Graf
8f6d584ee9 Fixed invoice tax rate 2026-07-30 21:16:06 +02:00
github-actions[bot]
1905cbf40c Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1148#issuecomment-5123895106
2026-07-29 22:12:52 +00:00
github-actions[bot]
c8f668238e Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1179#issuecomment-5123615992
2026-07-29 21:36:39 +00:00
Gregor Vostrak
bf11bacdee add vouch system requirement for PRs > 50 line changes 2026-07-29 23:22:32 +02:00
Gregor Vostrak
f3c6a0b8ae fix invitations not being respected during signup when email case differs 2026-07-28 17:07:53 +02:00
Gregor Vostrak
619c602571 cleanup and deduplicate breaks frontend tests 2026-07-28 16:52:36 +02:00
Gregor Vostrak
600daf44d9 add description/project labels to break placement modal for existing
time entries
2026-07-28 16:52:36 +02:00
Gregor Vostrak
a1d6c92806 insert breaks into work entries instead of carving them out; rollback on
failure system
2026-07-28 16:52:36 +02:00
Gregor Vostrak
ce1bd6a435 unify display values for work and break time totals 2026-07-28 16:52:36 +02:00
Gregor Vostrak
79d85227c2 bump ui to 0.0.22 and api to 0.0.7 2026-07-28 16:52:36 +02:00
Gregor Vostrak
885abf3333 promote breaks feature in sidebar banner 2026-07-28 16:52:36 +02:00
Gregor Vostrak
c0c8fee6be default time entry type filter to work for legacy reports 2026-07-28 16:52:36 +02:00
Gregor Vostrak
cbcd1e51f6 add break time entries and simplified time tracker ui 2026-07-28 16:52:36 +02:00
Constantin Graf
114a32536d Fixed update of member_id in time_entries.update and time_entries.updateMultiple
Removed usage of legacy user_id in TimeEntryController
2026-07-23 12:02:46 +02:00
Constantin Graf
ff8a0f065b Updated extension billing 2026-07-23 11:42:32 +02:00
Constantin Graf
44fd0ffb91 Fix .dockerignore 2026-07-23 11:40:19 +02:00
Constantin Graf
c07c62bfab Add extensions manifest 2026-07-21 16:39:27 +02:00
Constantin Graf
f599757761 Add .dockerignore file 2026-07-21 16:39:27 +02:00
dependabot[bot]
e54fd2460c Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:33 +02:00
dependabot[bot]
e997a2b39f Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:15 +02:00
Constantin Graf
f0de89b0e8 Prevent failed jobs for no longer existing projects and tasks 2026-07-11 16:00:34 +02:00
Constantin Graf
024283273a Updated email validation to stricter config 2026-07-11 16:00:34 +02:00
Candide
f01ab5fd9d Fix awkward wording for time tracker alert email
I just received this email today, and found the "you do that in solidtime" wording to be a bit awkward.
2026-07-11 15:57:33 +02:00
Gregor Vostrak
4fb18f343f allow missing client columns in clockify importer, fixes #1149 2026-07-09 16:16:25 +02:00
Gregor Vostrak
ba374c0371 add client name to projecttaskselect dropdown trigger 2026-07-09 13:53:08 +02:00
Gregor Vostrak
e302421765 make sure dropdown/combobox lists stay visible during close animation to
avoid layout shifts
2026-07-09 13:01:23 +02:00
Gregor Vostrak
c90c1ed8fa add stable secondary sorting based on id to the tests to avoid flakyness 2026-07-09 13:01:23 +02:00
Gregor Vostrak
75f5144b45 use pinned selection instead of fix items on dropdown open so async
loaded collections update the dropdown properly
2026-07-09 13:01:23 +02:00
Gregor Vostrak
53ecc58509 add validationa for project names in toggl importer 2026-07-09 13:01:23 +02:00
Gregor Vostrak
ac68091d3e add secondary name sorting as tie breaker in client and project tables 2026-07-09 13:01:23 +02:00
Gregor Vostrak
a97c02542b add unique id tiebreaker to paginated index endpoints to make pagination
stable (#1138)
2026-07-09 13:01:23 +02:00
Gregor Vostrak
a56942abcf Add virtualizer to ProjectDropdown, ClientDropdown and Reporting
Comboboxes; Remove redundant focus loop on Project/ClientDropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
61a0a7db5a remove the measure row logic and rely on static values only for the
virtualizer and remove duplicated focus trap to avoid infinite loop in
project task dropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
bc5af14a75 keep tasks visible when search term matches project or client name 2026-07-09 13:01:23 +02:00
Gregor Vostrak
95bfb45978 add virtualizer to ProjectTaskDropdown component to handle bigger
project lists
2026-07-09 13:01:23 +02:00
Gregor Vostrak
2207f676ee add pagination to client and project table 2026-07-09 13:01:23 +02:00
Gregor Vostrak
981bcbe091 add tests for tag match type (backend + e2e) 2026-06-26 14:53:37 +02:00
Gregor Vostrak
ccf3cec8c0 migrate tag match type buttons to reka-ui radio group for accessibility 2026-06-26 14:53:37 +02:00
Gregor Vostrak
23ea1500b0 Use an enum for tag match type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
4bbde04e28 Rename tag_filter parameter to tag_match_type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
caa36d2875 Fix not-contains tag filter dropping entries where tags is null 2026-06-26 14:53:37 +02:00
Beda Schmid
58d7b33366 Add filter to include/exclude tags
[Added]
- Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes.
2026-06-26 14:53:37 +02:00
Gregor Vostrak
50f57f6997 add support for activity columns in clockify exports 2026-06-25 12:10:44 +02:00
Gregor Vostrak
ab9f6e6afb adapt clockify importer to support task -> tasks column rename 2026-06-25 00:20:29 +02:00
Gregor Vostrak
2a40bb7edb add support for archived projects in the clockify importer 2026-06-25 00:08:42 +02:00
Gregor Vostrak
365f672cfc clarify import order for clockify and harvest 2026-06-25 00:02:41 +02:00
Gregor Vostrak
25f30fa820 fix destructive color contrast 2026-06-24 17:44:56 +02:00
Constantin Graf
37a8f43b23 Allow Clockify import without Billable column 2026-06-24 13:44:48 +02:00
Constantin Graf
c94aa8038d Add base path to the vite config to be able to tunnel all vite assets through a CDN 2026-06-20 21:55:29 +02:00
Constantin Graf
c9e84260b1 Updated GitHub actions 2026-06-19 16:53:40 +02:00
dependabot[bot]
9a53f903d8 Bump WyriHaximus/github-action-get-previous-tag from 1 to 2
Bumps [WyriHaximus/github-action-get-previous-tag](https://github.com/wyrihaximus/github-action-get-previous-tag) from 1 to 2.
- [Release notes](https://github.com/wyrihaximus/github-action-get-previous-tag/releases)
- [Commits](https://github.com/wyrihaximus/github-action-get-previous-tag/compare/v1...v2)

---
updated-dependencies:
- dependency-name: WyriHaximus/github-action-get-previous-tag
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:13:24 +02:00
dependabot[bot]
d50ce52b5b Bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:10:17 +02:00
dependabot[bot]
aea310e639 Bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:10:01 +02:00
dependabot[bot]
a94627321b Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:09:51 +02:00
dependabot[bot]
806d138fc9 Bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:09:37 +02:00
Constantin Graf
240dd809f4 Fixed organiztion page for employees 2026-06-18 17:30:54 +02:00
dependabot[bot]
dedb50ea73 Bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:32:01 +02:00
dependabot[bot]
6fe856fd31 Bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:31:39 +02:00
dependabot[bot]
20735c2fed Bump codecov/codecov-action from 5.5.1 to 7.0.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.1 to 7.0.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.5.1...v7.0.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:31:20 +02:00
dependabot[bot]
b14e40c64b Bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:30:41 +02:00
dependabot[bot]
f2277ff708 Bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:30:11 +02:00
Constantin Graf
6a197f7f34 Add password check to users.destroy and organizations.destroy 2026-06-18 13:27:43 +02:00
Constantin Graf
24c94af952 Add option to enable email verification locally for testing 2026-06-18 13:27:43 +02:00
Constantin Graf
111ce94150 Add comments to events about legacy events 2026-06-18 13:27:43 +02:00
Constantin Graf
49e69b85a6 Fixed middlwares after jetstream removal 2026-06-18 13:27:43 +02:00
Gregor Vostrak
90a5c2b87c add comprehensive 2 factor authentication e2e tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
6b01034bdd add back destroy other browser sessions endpoint (jetstream migration) 2026-06-18 13:27:43 +02:00
Gregor Vostrak
a4c400ef4e update e2e test setup to use user settings api endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
5d01b20dbf change redirects and references to new organization routes 2026-06-18 13:27:43 +02:00
Gregor Vostrak
24875f9424 add currency to organization update endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
4975cf1dab replace hardcoded inertia props with organization delete/update perms 2026-06-18 13:27:43 +02:00
Gregor Vostrak
b3fb04c4be remove unused inertia organization page props 2026-06-18 13:27:43 +02:00
Gregor Vostrak
0cc8d762a5 fix inertia backend role data structure after jetstream migration 2026-06-18 13:27:43 +02:00
Gregor Vostrak
e3e45161a1 call api for organization create/update/delete and switch 2026-06-18 13:27:43 +02:00
Constantin Graf
0c825a691e Fixed current organization after normal registration 2026-06-18 13:27:43 +02:00
Constantin Graf
24775d2c47 Added tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
c5c129b239 add switch current organization endpoint 2026-06-18 13:27:43 +02:00
Constantin Graf
89a9341d91 Removed Laravel Jetstream 2026-06-18 13:27:43 +02:00
Gregor Vostrak
bffd0773be remove jetstream inertia properties; remove unused ApiTokenManager; 2026-06-18 13:27:43 +02:00
Gregor Vostrak
56ea0ed90e move banners on login and register cards into the cards 2026-06-18 13:27:43 +02:00
Gregor Vostrak
24c19e7e7c add pending email cancel button 2026-06-18 13:27:43 +02:00
Constantin Graf
734aab6353 Replaces all Jetstream model trait functions and relations 2026-06-18 13:27:43 +02:00
Constantin Graf
5eb1a6a006 Add reset pending email endpoint to user controller 2026-06-18 13:27:43 +02:00
Gregor Vostrak
d0334bd730 move user delete to api endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
fe4e903203 use api routes for profile information updates 2026-06-18 13:27:43 +02:00
Gregor Vostrak
43e42ace54 show null billable rate as empty not as 0 to avoid confusion 2026-06-18 13:27:43 +02:00
Gregor Vostrak
84728f0914 fix e2e selectors to adapt to reka-ui change; 2026-06-18 13:27:43 +02:00
Gregor Vostrak
8f02f98afc add pending email to UserResource and update openapi client 2026-06-18 13:27:43 +02:00
Gregor Vostrak
c1ecd0eff5 update ui package dependencies; update lucide imports 2026-06-18 13:27:43 +02:00
Gregor Vostrak
486b5d2152 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-06-18 13:27:43 +02:00
Gregor Vostrak
5476b4e5a9 update npm dependencies 2026-06-18 13:27:43 +02:00
Gregor Vostrak
ceec4cbf62 add profile page e2e tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
372c7b02a0 update email address change info to use session based banners 2026-06-18 13:27:43 +02:00
Gregor Vostrak
cb8047028c add 1MB photo upload limit 2026-06-18 13:27:43 +02:00
Gregor Vostrak
e29be581fa add photo delete logic to user update endpoint 2026-06-18 13:27:43 +02:00
Constantin Graf
02da133fb3 Updated composer dependencies 2026-06-18 13:27:43 +02:00
Constantin Graf
d00fe2baff Add more tests 2026-06-18 13:27:43 +02:00
Constantin Graf
a73fe59892 Add migration to lower case the user emails 2026-06-18 13:27:43 +02:00
Constantin Graf
5d744d70c4 Migrate permission away from Jetstream; Moved update user to REST API 2026-06-18 13:27:43 +02:00
Gregor Vostrak
12d58ee42c rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-06-18 13:27:43 +02:00
Gregor Vostrak
f9be92894e add banners for invitation accept 2026-06-18 13:27:43 +02:00
Constantin Graf
a30d192ea2 Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-06-18 13:27:43 +02:00
dependabot[bot]
d732064f31 Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 22:58:27 +02:00
Gregor Vostrak
cb5c2547f4 fix profile setting sidebar alignment 2026-06-03 12:24:53 +02:00
Gregor Vostrak
13a25524f3 add saved/saving/error indicators to timesheets 2026-06-02 17:14:32 +02:00
Gregor Vostrak
112f6aa6a6 add invoice clone to openapi client, expose DetailedInvoice type 2026-05-29 19:07:55 +02:00
Gregor Vostrak
8eab0485c9 revert reka-ui update; fix DST cellMath; 2026-05-29 17:14:52 +02:00
Gregor Vostrak
0aa0f0bd77 use cn helper for alert-dialog modals 2026-05-29 17:14:52 +02:00
Gregor Vostrak
eb63c4ef03 fix light mode timesheet background and add missing aria-label 2026-05-29 17:14:52 +02:00
Gregor Vostrak
54fffd07bc add timesheet unit and e2e tests; add unit test CI setup 2026-05-29 17:14:52 +02:00
Gregor Vostrak
da235dfdc8 remove special “Add new project” state in TimeTrackerProjectTaskDropdown 2026-05-29 17:14:52 +02:00
Gregor Vostrak
0debdddef9 set min release age for npm packages to 7 days to prevent supply chain attacks 2026-05-29 17:14:52 +02:00
Gregor Vostrak
62354cfe8b remove timetrackerprojecttaskdropdown test without setup 2026-05-29 17:14:52 +02:00
Gregor Vostrak
396e7b2b6b fix DST boundary issue in timesheets 2026-05-29 17:14:52 +02:00
Gregor Vostrak
221889ff87 fix "No project" duplicating rows, unify no project senitel to null 2026-05-29 17:14:52 +02:00
Gregor Vostrak
7ce3fa2740 change TimeEntryFilter start filter to be inclusive 2026-05-29 17:14:52 +02:00
Gregor Vostrak
df34014bfe fix e2e tests 2026-05-29 17:14:52 +02:00
Gregor Vostrak
faf3ee471c fix formatting 2026-05-29 17:14:52 +02:00
Gregor Vostrak
866e5d8594 clamp running time entry duration to min 0 for FullCalendarHeaderDuration calc 2026-05-29 17:14:52 +02:00
Gregor Vostrak
72cd0b6f05 fix formatting 2026-05-29 17:14:52 +02:00
Gregor Vostrak
6d93e48b1d add missing dayjs plugins for isSameOrBefore and isSameOrAfter 2026-05-29 17:14:52 +02:00
Gregor Vostrak
09af0f775f add timesheets page 2026-05-29 17:14:52 +02:00
Gregor Vostrak
1cc000a584 fix local storage filter migration state for visibility filter 2026-05-26 11:37:24 +02:00
Gregor Vostrak
1a754f6756 improve modal and field group spacing for project modal layout 2026-05-26 11:15:15 +02:00
Gregor Vostrak
d69d25d059 add project table visibility filter 2026-05-26 11:15:15 +02:00
Gregor Vostrak
0e15d9d9c2 add project visibility ui 2026-05-26 11:15:15 +02:00
dependabot[bot]
7d9ecd9526 Bump aglipanci/laravel-pint-action from 2.5 to 2.6
Bumps [aglipanci/laravel-pint-action](https://github.com/aglipanci/laravel-pint-action) from 2.5 to 2.6.
- [Release notes](https://github.com/aglipanci/laravel-pint-action/releases)
- [Commits](https://github.com/aglipanci/laravel-pint-action/compare/2.5...2.6)

---
updated-dependencies:
- dependency-name: aglipanci/laravel-pint-action
  dependency-version: '2.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:28:48 +02:00
dependabot[bot]
3a17f80f99 Bump codecov/codecov-action from 5.4.3 to 5.5.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.3 to 5.5.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.3...v5.5.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:14:44 +02:00
dependabot[bot]
e29ea2ea42 Bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:13:14 +02:00
dependabot[bot]
fb6e4639ce Bump actions/download-artifact from 4 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:12:01 +02:00
dependabot[bot]
69bc41988a Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:11:19 +02:00
Gregor Vostrak
f7663b1c8b Clarify out of scope items for vulnerability reports
Added out of scope section for vulnerability reporting.
2026-05-18 19:21:32 +02:00
Gregor Vostrak
793bd11dcf remove member, invitation, and owner email disclosure from Teams/Show inertia props
The Teams/Show Inertia page serialized members, pending invitations, and the
owner email into props using only a belongsToTeam authorization gate, while
the corresponding API endpoints correctly enforced members:view and
invitations:view. The serialized data was unused by the live UI (the
TeamMemberManager partial that referenced it was orphaned), so dropping the
fields removes the disclosure surface without functional impact. The owner
card retains name and photo.
2026-05-18 19:04:57 +02:00
Gregor Vostrak
77a62afd69 add alphabetic sorting to multiselect dropdowns 2026-04-29 18:32:05 +02:00
Gregor Vostrak
b73aa543fd Merge commit from fork 2026-04-21 21:12:30 +02:00
Gregor Vostrak
2d6f9e514f add groupSimilarTimeEntries to TimeEntryGroupedTable 2026-04-21 20:44:33 +02:00
Gregor Vostrak
f8e668790b Fix typo in project name in README.md 2026-04-18 04:27:50 +02:00
utlark
77a5e979c6 Added the ability to disable group similar time entries (#1054)
* Added the ability to disable group similar time entries

* Fix E2E test for Group similar time entries

* Simplify `TimeEntryGroupedTable` by replacing ternary with early return logic

* Refactor time entry grouping settings: rename storage key, move logic into a dedicated module

* Replace fixed `waitForTimeout` calls in E2E tests with element-based waits and assertions

* Run frontend linting and formatting for changes
2026-04-17 16:44:59 +02:00
Gregor Vostrak
353a579850 chore: bump ui package version 2026-04-17 14:46:36 +02:00
Gregor Vostrak
bd44a2b376 fix e2e tests for new duration reporting format logic 2026-04-17 14:36:56 +02:00
Gregor Vostrak
277dbaf6eb promote duration formats that omit seconds to HH:mm:ss in reporting
views and exports
2026-04-17 12:15:26 +02:00
Gregor Vostrak
1cf33ddb3f improve dark mode color palette; rework font weights throughout the
interface
2026-04-15 15:35:20 +02:00
Gregor Vostrak
84cd0d572d bump ui package version 2026-04-08 23:18:29 +02:00
Gregor Vostrak
f37b86f377 chore: remove unused formatActivityDuration function 2026-04-08 14:49:37 +02:00
Gregor Vostrak
1e7364fc4b show calendar activities more prominently when no time entry exists 2026-04-08 14:43:09 +02:00
Gregor Vostrak
8cbc9838c9 fix minimal layout shift on time entry select and migrate to ui button 2026-04-07 21:42:34 +02:00
Gregor Vostrak
71c8992e31 Fix getLocalizedDayJsFromMinutes handling negative minute values 2026-03-31 13:56:30 +02:00
Gregor Vostrak
53d91b65d6 fix: use timezoned dates in public report endpoint tests
Replace travelTo + now() with Carbon::now($timezone)->startOfDay() to eliminate flakiness when tests run near midnight UTC, where the UTC and Vienna dates can differ.
2026-03-31 13:21:54 +02:00
Gregor Vostrak
0c88a10eb5 improve calendar current day styling 2026-03-30 00:58:40 +02:00
Gregor Vostrak
dd7b23958a fix gotenberg url in CI 2026-03-30 00:07:57 +02:00
Gregor Vostrak
1eb066f5aa Add E2E test for project name prefill 2026-03-29 23:55:10 +02:00
ShrootBuck
b1287c6a0a Prefill project name in create modal
Add optional initialProjectName prop to ProjectCreateModal and use it
to initialize the project's name. Pass the TimeTracker dropdown's
searchValue as initial-project-name so the create form is prefilled.
2026-03-29 23:55:10 +02:00
Gregor Vostrak
815abb5980 improve drag handle hit area and activity tooltip placement 2026-03-29 23:14:01 +02:00
Gregor Vostrak
e2f859be27 fix calendar scroll down on load; bump ui package version 2026-03-29 23:02:22 +02:00
Gregor Vostrak
3d26fcaefe Fix DST-related timezone offset when creating/resizing/dragging calendar
events
2026-03-29 22:55:50 +02:00
Gregor Vostrak
1e73a90f9d chore: bump ui version 2026-03-29 22:09:01 +02:00
Gregor Vostrak
0f8f906e5c clarify naming on activity type 2026-03-27 00:37:29 +01:00
469 changed files with 28982 additions and 6483 deletions

54
.dockerignore Normal file
View File

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

View File

@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://0.0.0.0:3000
GOTENBERG_URL=http://localhost:3000
# Octane
OCTANE_SERVER=frankenphp

View File

@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://gotenberg:3000
# Octane
OCTANE_SERVER=frankenphp
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing

25
.github/VOUCHED.td vendored Normal file
View File

@@ -0,0 +1,25 @@
# 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
bufferhead-code
candideu
kasparrosin
korridor
onatcer
shrootbuck
smilebeda
thespyder
utlark

View File

@@ -8,6 +8,8 @@ on:
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
@@ -26,7 +28,7 @@ jobs:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
- runs-on: "blacksmith-4vcpu-ubuntu-2404-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
@@ -35,7 +37,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -46,9 +48,9 @@ jobs:
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
uses: "WyriHaximus/github-action-get-previous-tag@v2"
with:
prefix: "v"
pattern: "v*[0-9].*[0-9].*[0-9]"
- name: "Get version"
id: release-version
@@ -91,14 +93,23 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v7
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@v4
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-invoicing
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
@@ -124,27 +135,27 @@ jobs:
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: docker/prod/Dockerfile
@@ -163,7 +174,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
@@ -171,31 +182,31 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_REPO }}

View File

@@ -8,6 +8,8 @@ on:
pull_request:
paths:
- '.github/workflows/build-private.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
permissions:
@@ -16,13 +18,13 @@ permissions:
name: Build - Private
jobs:
build:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -33,9 +35,9 @@ jobs:
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
uses: "WyriHaximus/github-action-get-previous-tag@v2"
with:
prefix: "v"
pattern: "v*[0-9].*[0-9].*[0-9]"
- name: "Get version"
id: version
@@ -68,14 +70,27 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v7
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@v4
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-billing
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
@@ -93,9 +108,10 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-services
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
@@ -111,9 +127,10 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-invoicing
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
@@ -160,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: rg.fr-par.scw.cloud/solidtime
username: nologin
@@ -168,7 +185,7 @@ jobs:
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: rg.fr-par.scw.cloud/solidtime/solidtime
tags: |
@@ -179,13 +196,13 @@ jobs:
type=sha,format=long
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: "Build and push"
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
build-args: |

View File

@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- '.github/workflows/build-public.yml'
- '.dockerignore'
- 'docker/prod/**'
workflow_dispatch:
@@ -27,7 +28,7 @@ jobs:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
- runs-on: "blacksmith-4vcpu-ubuntu-2404-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
@@ -36,7 +37,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -47,9 +48,9 @@ jobs:
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
uses: "WyriHaximus/github-action-get-previous-tag@v2"
with:
prefix: "v"
pattern: "v*[0-9].*[0-9].*[0-9]"
- name: "Get version"
id: release-version
@@ -92,7 +93,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'
@@ -109,34 +110,34 @@ jobs:
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: docker/prod/Dockerfile
@@ -155,7 +156,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
@@ -163,37 +164,37 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_REPO }}

View File

@@ -8,7 +8,7 @@ permissions:
jobs:
api_docs:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
services:
@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -52,7 +52,7 @@ jobs:
run: php artisan scramble:export --path=build/api-docs.json
- name: "Upload API docs to GitHub"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: api-docs.json
path: build/api-docs.json

View File

@@ -6,12 +6,12 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- 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@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -4,15 +4,15 @@ on: [push]
jobs:
format-check:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -6,15 +6,15 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -5,17 +5,17 @@ permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -5,15 +5,15 @@ permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

27
.github/workflows/npm-test-unit.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: NPM Test Unit
on: [push]
permissions:
contents: read
jobs:
build:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
env:
TZ: UTC
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Use Node.js"
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Run vitest"
run: npm run test:unit

View File

@@ -5,12 +5,12 @@ permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- 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@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -4,12 +4,12 @@ permissions:
contents: read
jobs:
phpstan:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -4,7 +4,7 @@ permissions:
contents: read
jobs:
phpunit:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
strategy:
matrix:
@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- 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@v4
- uses: actions/setup-node@v7
with:
node-version: '20.x'
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -4,14 +4,14 @@ permissions:
contents: read
jobs:
pint:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5
uses: aglipanci/laravel-pint-action@2.6
with:
configPath: "pint.json"

View File

@@ -4,7 +4,7 @@ permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 60
strategy:
fail-fast: false
@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: "Setup node"
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'
@@ -86,7 +86,7 @@ jobs:
MAILPIT_BASE_URL: 'http://localhost:8025'
- name: "Upload blob report"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: blob-report-${{ matrix.shardIndex }}
@@ -96,13 +96,13 @@ jobs:
merge-reports:
if: always()
needs: [test]
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: "Setup node"
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'
@@ -110,7 +110,7 @@ jobs:
run: npm ci
- name: "Download blob reports"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: all-blob-reports
pattern: blob-report-*
@@ -120,7 +120,7 @@ jobs:
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: "Upload merged HTML report"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/

75
.github/workflows/vouch-check-pr.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
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: blacksmith-4vcpu-ubuntu-2404
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

@@ -0,0 +1,33 @@
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: blacksmith-4vcpu-ubuntu-2404
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

@@ -0,0 +1,35 @@
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: blacksmith-4vcpu-ubuntu-2404
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 }}

4
.gitignore vendored
View File

@@ -26,9 +26,10 @@ 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
@@ -42,3 +43,4 @@ yarn-error.log
/data
/config/caddy
/config/composer
/AGENTS.md

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=7

View File

@@ -12,6 +12,22 @@ 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 weve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Dont worry - the process is quick and only takes a few clicks.

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source Time Tracker
# solidtime - The modern Open-Source TimeTracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)
@@ -39,6 +39,8 @@ 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

@@ -3,3 +3,18 @@
## Reporting a Vulnerability
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
## Out of scope
Reports we typically won't issue an advisory for:
* Theoretical findings without a working PoC
* Raw scanner output without manual validation
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
* Org owners or admins acting destructively within their own organization
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)

View File

@@ -16,7 +16,6 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
use Log;
class CreateNewUser implements CreatesNewUsers
@@ -55,7 +54,7 @@ class CreateNewUser implements CreatesNewUsers
}),
],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
'terms' => ['accepted', 'required'],
'newsletter_consent' => [
'boolean',
],

View File

@@ -4,13 +4,9 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Exceptions\MovedToApiException;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -24,56 +20,6 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [
'nullable',
'mimes:jpg,jpeg,png',
'max:1024',
],
'timezone' => [
'required',
'timezone:all',
],
'week_start' => [
'required',
Rule::enum(Weekday::class),
],
])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) {
$user->updateProfilePhoto($input['photo']);
}
if ($input['email'] !== $user->email) {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save();
$user->sendEmailVerificationNotification();
} else {
$user->forceFill([
'name' => $input['name'],
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save();
}
throw new MovedToApiException;
}
}

View File

@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
class AddOrganizationMember implements AddsTeamMembers
{
/**
* Add a new team member to the given team.
*/
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
$this->validate($organization, $email, $role);
$newOrganizationMember = User::query()
->where('email', $email)
->where('is_placeholder', '=', false)
->firstOrFail();
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
}
/**
* Validate the add member operation.
*/
protected function validate(Organization $organization, string $email, ?string $role): void
{
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules())->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
/**
* Get the validation rules for adding a team member.
*
* @return array<string, array<ValidationRule|Rule|string|In>>
*/
protected function rules(): array
{
return [
'email' => [
'required',
'email',
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false);
})->withMessage(__('We were unable to find a registered user with this email address.')),
],
'role' => [
'required',
'string',
Rule::in([
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
]),
],
];
}
/**
* Ensure that the user is not already on the team.
*/
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{
return function ($validator) use ($team, $email): void {
$validator->errors()->addIf(
$team->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);
};
}
}

View File

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Events\AfterCreateOrganization;
use App\Models\Organization;
use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\CreatesTeams;
use Laravel\Jetstream\Jetstream;
class CreateOrganization implements CreatesTeams
{
/**
* Validate and create a new team for the given user.
*
* @param array<string, string> $input
*
* @throws AuthorizationException
* @throws ValidationException
*/
public function create(User $user, array $input): Organization
{
Gate::forUser($user)->authorize('create', Jetstream::newTeamModel());
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam');
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$currency = null;
if ($ipLookupResponse !== null) {
$currency = $ipLookupResponse->currency;
}
$organization = app(OrganizationService::class)->createOrganization(
$input['name'],
$user,
false,
$currency
);
$user->switchTeam($organization);
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization);
return $organization;
}
}

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Service\DeletionService;
use Laravel\Jetstream\Contracts\DeletesTeams;
class DeleteOrganization implements DeletesTeams
{
/**
* Delete the given team.
*/
public function delete(Organization $organization): void
{
/** @see ValidateOrganizationDeletion */
app(DeletionService::class)->deleteOrganization($organization);
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use App\Service\DeletionService;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*
* @throws ValidationException
*/
public function delete(User $user): void
{
try {
app(DeletionService::class)->deleteUser($user);
} catch (ApiException $exception) {
throw ValidationException::withMessages([
'password' => $exception->getTranslatedMessage(),
]);
}
}
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use Exception;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
class InviteOrganizationMember implements InvitesTeamMembers
{
/**
* Invite a new team member to the given team.
*
* @throws Exception
*/
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
{
throw new MovedToApiException;
}
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use Exception;
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
class RemoveOrganizationMember implements RemovesTeamMembers
{
/**
* Remove the team member from the given team.
*
* @throws Exception
*/
public function remove(User $user, Organization $organization, User $teamMember): void
{
throw new MovedToApiException;
}
}

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Exceptions\MovedToApiException;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Exception;
class UpdateMemberRole
{
/**
* Update the role for the given team member.
*
* @throws Exception
*/
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
{
throw new MovedToApiException;
}
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Rules\CurrencyRule;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\UpdatesTeamNames;
class UpdateOrganization implements UpdatesTeamNames
{
/**
* Validate and update the given team's name.
*
* @param array<string, string> $input
*
* @throws AuthorizationException
* @throws ValidationException
*/
public function update(User $user, Organization $organization, array $input): void
{
Gate::forUser($user)->authorize('update', $organization);
Validator::make($input, [
'name' => [
'required',
'string',
'max:255',
],
'currency' => [
'required',
'string',
new CurrencyRule,
],
])->validateWithBag('updateTeamName');
$organization->forceFill([
'name' => $input['name'],
'currency' => $input['currency'],
])->save();
}
}

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
class ValidateOrganizationDeletion
{
/**
* Validate that the team can be deleted by the given user.
*
* @param User $user Authenticated user
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*/
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException;
}
}
}

View File

@@ -69,7 +69,7 @@ class UserCreateCommand extends Command
);
});
/** @var Organization|null $organization */
$organization = $user->ownedTeams->first();
$organization = $user->ownedOrganizations->first();
if ($organization === null) {
throw new LogicException('User does not have an organization');
}

View File

@@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum Role: string
{
use LaravelEnumHelper;
case Owner = 'owner';
case Admin = 'admin';
case Manager = 'manager';

View File

@@ -0,0 +1,16 @@
<?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,6 +21,7 @@ enum TimeEntryAggregationType: string
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
case Type = 'type';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

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

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy TeamMemberAdded event.
*/
class MemberAdded
{
use Dispatchable;
public Member $member;
public Organization $organization;
public User $user;
public function __construct(Member $member, Organization $organization, User $user)
{
$this->member = $member;
$this->organization = $organization;
$this->user = $user;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy AddingTeamMember event.
*/
class MemberAdding
{
use Dispatchable;
public User $user;
public Organization $organization;
public Role $role;
public function __construct(User $user, Organization $organization, Role $role)
{
$this->user = $user;
$this->organization = $organization;
$this->role = $role;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy InvitingTeamMember event.
*/
class OrganizationInvitationAdding
{
use Dispatchable;
public Organization $organization;
public string $email;
public Role $role;
public User $inviter;
public function __construct(
Organization $organization,
string $email,
Role $role,
User $inviter
) {
$this->role = $role;
$this->email = $email;
$this->organization = $organization;
$this->inviter = $inviter;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{
public const string KEY = 'user_resend_email_verification_no_pending_email';
}

View File

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

View File

@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
TextInput::make('queue')->disabled(),
// make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
Textarea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
])->columns(4);
}

View File

@@ -39,7 +39,7 @@ class OrganizationInvitationResource extends Resource
->required(),
Select::make('role')
->options(Role::class),
Forms\Components\Select::make('organization_id')
Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])

View File

@@ -55,7 +55,7 @@ class OrganizationResource extends Resource
->label('Is personal?')
->hiddenOn(['create'])
->required(),
Forms\Components\Select::make('user_id')
Select::make('user_id')
->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email'])
@@ -76,7 +76,7 @@ class OrganizationResource extends Resource
Select::make('time_format')
->options(TimeFormat::toSelectArray())
->required(),
Forms\Components\Select::make('currency')
Select::make('currency')
->label('Currency')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
@@ -114,22 +114,22 @@ class OrganizationResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('personal_team')
->boolean()
->label('Is personal?')
->sortable(),
Tables\Columns\TextColumn::make('owner.email')
TextColumn::make('owner.email')
->sortable(),
Tables\Columns\TextColumn::make('currency'),
TextColumn::make('currency'),
TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@@ -223,7 +223,7 @@ class OrganizationResource extends Resource
return $select;
}),
Forms\Components\Select::make('timezone')
Select::make('timezone')
->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
->searchable()

View File

@@ -21,7 +21,7 @@ use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'teamInvitations';
protected static string $relationship = 'organizationInvitations';
protected static ?string $title = 'Invitations';
@@ -64,7 +64,7 @@ class InvitationsRelationManager extends RelationManager
$ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user());
}),
])
->actions([

View File

@@ -49,13 +49,13 @@ class UsersRelationManager extends RelationManager
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'),
TextColumn::make('name'),
TextColumn::make('role'),
TextColumn::make('billable_rate')
->money($organization->currency, divideBy: 100),
])
->headerActions([
Tables\Actions\AttachAction::make()
AttachAction::make()
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),

View File

@@ -63,11 +63,11 @@ class ReportResource extends Resource
return $record->getRawOriginal('properties');
})
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
@@ -78,10 +78,10 @@ class ReportResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
TextColumn::make('description')
->searchable()
->sortable(),
ToggleColumn::make('is_public')
@@ -90,10 +90,10 @@ class ReportResource extends Resource
TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),

View File

@@ -93,11 +93,11 @@ class TimeEntryResource extends Resource
($record->end?->toDateTimeString('minute') ?? '...').')';
})
->label('Time'),
Tables\Columns\TextColumn::make('organization.name')
TextColumn::make('organization.name')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->sortable(),
])
->filters([

View File

@@ -12,6 +12,7 @@ use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelat
use App\Models\User;
use App\Service\DeletionService;
use App\Service\TimezoneService;
use App\Service\UserService;
use Brick\Money\ISOCurrencyProvider;
use Exception;
use Filament\Forms;
@@ -47,17 +48,17 @@ class UserResource extends Resource
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('id')
TextInput::make('id')
->label('ID')
->disabled()
->visibleOn(['update', 'show'])
->readOnly()
->maxLength(255),
Forms\Components\TextInput::make('name')
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
TextInput::make('email')
->label('Email')
->required()
->rules($record?->is_placeholder ? [] : [
@@ -65,7 +66,7 @@ class UserResource extends Resource
->ignore($record?->getKey()),
])
->rule([
'email',
'email:rfc,strict',
])
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')
@@ -179,7 +180,7 @@ class UserResource extends Resource
])
->actions([
Impersonate::make()->before(function (User $record): void {
if ($record->currentTeam === null) {
if ($record->currentOrganization === null) {
$organization = $record->organizations()->where('personal_team', '=', true)->first();
if ($organization === null) {
$organization = $record->organizations()->first();
@@ -187,8 +188,7 @@ class UserResource extends Resource
if ($organization === null) {
throw new Exception('User has no organization');
}
$record->currentTeam()->associate($organization);
$record->save();
app(UserService::class)->switchCurrentOrganization($record, $organization);
}
}),
Tables\Actions\EditAction::make(),

View File

@@ -16,7 +16,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
{
protected static ?string $title = 'Owned Organizations';
protected static string $relationship = 'ownedTeams';
protected static string $relationship = 'ownedOrganizations';
public function form(Form $form): Form
{

View File

@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
/**
* List all api token of the currently authenticated user
*
* This endpoint is independent of organization.
* This endpoint is independent of the organization.
*
* @operationId getApiTokens
*

View File

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

View File

@@ -40,8 +40,9 @@ class InvitationController extends Controller
{
$this->checkPermission($organization, 'invitations:view');
$invitations = $organization->teamInvitations()
$invitations = $organization->organizationInvitations()
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations);
@@ -63,7 +64,7 @@ class InvitationController extends Controller
$email = $request->getEmail();
$role = $request->getRole();
$invitationService->inviteUser($organization, $email, $role);
$invitationService->inviteUser($organization, $email, $role, $this->user());
return response()->json(null, 204);
}

View File

@@ -61,6 +61,7 @@ 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);
@@ -192,7 +193,7 @@ class MemberController extends Controller
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
}
$invitationService->inviteUser($organization, $user->email, Role::Employee);
$invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user());
return response()->json(null, 204);
}

View File

@@ -5,11 +5,19 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\DeletionService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class OrganizationController extends Controller
{
@@ -43,6 +51,9 @@ class OrganizationController extends Controller
if ($request->getName() !== null) {
$organization->name = $request->getName();
}
if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency();
}
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
@@ -67,6 +78,9 @@ 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;
@@ -80,4 +94,46 @@ class OrganizationController extends Controller
return new OrganizationResource($organization, true);
}
/**
* Create organization
*
* @operationId createOrganization
*/
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
{
$user = $this->user();
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
$currency = $ipLookupResponse?->currency;
$organization = $organizationService->createOrganization(
$request->getName(),
$user,
false,
$currency
);
app(UserService::class)->switchCurrentOrganization($user, $organization);
AfterCreateOrganization::dispatch($organization);
return new OrganizationResource($organization, true);
}
/**
* Delete organization
*
* @operationId deleteOrganization
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse
{
$this->checkPermission($organization, 'organizations:delete');
$deletionService->deleteOrganization($organization);
return response()->json(null, 204);
}
}

View File

@@ -62,6 +62,7 @@ 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,6 +49,7 @@ 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,9 +57,10 @@ 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());
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();

View File

@@ -47,6 +47,7 @@ class ReportController extends Controller
$reports = Report::query()
->orderBy('created_at', 'desc')
->orderBy('id')
->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default'));
@@ -96,6 +97,7 @@ 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;
@@ -110,6 +112,7 @@ 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,6 +42,7 @@ 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,6 +84,7 @@ 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,6 +6,7 @@ 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;
@@ -59,7 +60,7 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller
{
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void
{
if (! $organization->prevent_overlapping_time_entries) {
return;
@@ -67,7 +68,7 @@ class TimeEntryController extends Controller
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id)
->where('member_id', $member->getKey())
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
@@ -107,8 +108,8 @@ class TimeEntryController extends Controller
/**
* Get time entries in organization
*
* 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.
* 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.
*
* @return TimeEntryCollection<TimeEntryResource>
*
@@ -118,16 +119,17 @@ class TimeEntryController extends Controller
*/
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{
/** @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()) {
$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()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count();
@@ -158,7 +160,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(),
'user_id' => $request->input('user_id'),
'member_id' => $request->input('member_id'),
'auth_user_id' => Auth::id(),
'limit' => $limit,
]);
@@ -194,7 +196,8 @@ class TimeEntryController extends Controller
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('start', 'desc');
->orderBy('time_entries.start', 'desc')
->orderBy('time_entries.id');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
@@ -203,10 +206,11 @@ 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'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$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();
}
@@ -220,9 +224,10 @@ class TimeEntryController extends Controller
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
/** @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()) {
$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()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -239,7 +244,7 @@ class TimeEntryController extends Controller
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery->with([
'task',
'client',
@@ -262,7 +267,7 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
null,
@@ -369,9 +374,10 @@ class TimeEntryController extends Controller
*/
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{
/** @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()) {
$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()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -382,7 +388,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -418,9 +424,10 @@ class TimeEntryController extends Controller
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
/** @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()) {
$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()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -436,7 +443,7 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -559,10 +566,11 @@ 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'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$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();
}
@@ -579,7 +587,7 @@ class TimeEntryController extends Controller
{
/** @var Member $member */
$member = Member::query()->findOrFail($request->input('member_id'));
if ($member->user_id === Auth::id()) {
if ($member->getKey() === $this->member($organization)->getKey()) {
$this->checkPermission($organization, 'time-entries:create:own');
} else {
$this->checkPermission($organization, 'time-entries:create:all');
@@ -626,12 +634,13 @@ class TimeEntryController extends Controller
*/
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{
/** @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');
$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())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all');
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
}
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
@@ -660,6 +669,10 @@ 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();
@@ -689,6 +702,7 @@ 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');
@@ -713,6 +727,9 @@ 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;
@@ -739,16 +756,33 @@ class TimeEntryController extends Controller
continue;
}
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
$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();
@@ -789,7 +823,8 @@ class TimeEntryController extends Controller
*/
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{
if ($timeEntry->member->user_id === Auth::id()) {
$member = $this->member($organization);
if ($timeEntry->member_id === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
@@ -846,7 +881,7 @@ class TimeEntryController extends Controller
continue;
}
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
$error->push($id);
continue;

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Service\TimezoneService;
use Illuminate\Http\JsonResponse;
class TimeZoneController extends Controller
{
/**
* Get all timezones
*
* @response object{key: string}[]
*
* @operationId getTimezones
*/
public function index(): JsonResponse
{
$timezones = app(TimezoneService::class)->getTimezones();
$response = [];
foreach ($timezones as $timezone) {
$response[] = (object) [
'key' => $timezone,
];
}
return response()->json($response);
}
}

View File

@@ -4,15 +4,30 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
use App\Http\Requests\V1\User\UserDestroyRequest;
use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest;
use App\Http\Requests\V1\User\UserUpdateRequest;
use App\Http\Resources\V1\User\UserResource;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\Organization;
use App\Models\User;
use App\Service\DeletionService;
use App\Service\UserService;
use App\Support\Base64File;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserController extends Controller
{
/**
* Get the current user
*
* This endpoint is independent of organization.
* This endpoint is independent of the organization.
*
* @operationId getMe
*
@@ -24,4 +39,169 @@ class UserController extends Controller
return new UserResource($user);
}
/**
* Update the current organization of the current user
*
* Switches the organization that the user is currently working in. The user
* must be a member of the given organization. This endpoint is independent of
* the organization.
*
* @operationId updateMyCurrentOrganization
*
* @throws AuthorizationException
*/
public function updateMyCurrentOrganization(UserUpdateCurrentOrganizationRequest $request, UserService $userService): UserResource
{
$user = $this->user();
/** @var Organization|null $organization */
$organization = $user->organizations()
->whereKey($request->getOrganizationId())
->first();
if ($organization === null) {
throw new AuthorizationException;
}
$userService->switchCurrentOrganization($user, $organization);
return new UserResource($user->refresh());
}
/**
* Update the current user
*
* This endpoint is independent of the organization.
*
* @operationId updateUser
*/
public function update(User $user, UserUpdateRequest $request): UserResource
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($request->hasPhotoKey()) {
$photoDisk = (string) config('filesystems.public');
$previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto();
if ($newPhoto === null) {
$user->profile_photo_path = null;
} else {
$decoded = Base64File::decode($newPhoto);
assert($decoded !== null);
$extension = Base64File::extension($decoded['mime_type']);
assert($extension !== null);
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
Storage::disk($photoDisk)->put($photoPath, $decoded['data'], 'public');
$user->profile_photo_path = $photoPath;
}
if ($previousPhotoPath !== null) {
Storage::disk($photoDisk)->delete($previousPhotoPath);
}
}
$emailToVerify = null;
$email = $request->getEmail();
if ($email !== null && $email !== Str::lower($user->email)) {
$emailToVerify = $email;
$user->pending_email = $email;
}
if ($request->getName() !== null) {
$user->name = $request->getName();
}
if ($request->getTimezone() !== null) {
$user->timezone = $request->getTimezone();
}
if ($request->getWeekStart() !== null) {
$user->week_start = $request->getWeekStart();
}
$user->save();
if ($emailToVerify !== null) {
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
}
return new UserResource($user);
}
/**
* Reset the pending email for a user.
*
* This endpoint is independent of the organization.
*
* @operationId resetUserPendingEmail
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
*/
public function resetPendingEmail(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$user->pending_email = null;
$user->save();
return response()->json(null, 204);
}
/**
* Resend the pending email update verification email.
*
* This endpoint is independent of the organization.
*
* @operationId resendUserEmailVerification
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
*/
public function resendEmailVerification(User $user): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
if ($user->pending_email === null) {
throw new UserResendEmailVerificationNoPendingEmailApiException;
}
Mail::to($user->pending_email)
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
return response()->json(null, 204);
}
/**
* Handles the deletion of a user.
*
* This endpoint is independent of the organization.
*
* @operationId deleteUser
*
* @param User $user The user instance to be deleted.
* @param DeletionService $deletionService The service responsible for performing the user deletion.
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}
$deletionService->deleteUser($user);
return response()->json(null, 204);
}
}

View File

@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
/**
* Get the memberships of the current user
*
* This endpoint is independent of organization.
* This endpoint is independent of the organization.
*
* @operationId getMyMemberships
*

View File

@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
/**
* Get the active time entry of the current user
*
* This endpoint is independent of organization.
* This endpoint is independent of the organization.
*
* @operationId getMyActiveTimeEntry
*/

View File

@@ -59,7 +59,7 @@ class Controller extends BaseController
protected function currentOrganization(): Organization
{
$user = $this->user();
$organization = $user->currentTeam;
$organization = $user->currentOrganization;
if ($organization === null) {
$organization = $user->organizations()->first();
}

View File

@@ -4,4 +4,21 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
abstract class Controller extends \App\Http\Controllers\Controller {}
use App\Models\Organization;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
abstract class Controller extends \App\Http\Controllers\Controller
{
public function __construct(
protected PermissionStore $permissionStore,
) {}
/**
* @throws AuthorizationException
*/
protected function hasPermission(Organization $organization, string $permission): bool
{
return $this->permissionStore->has($organization, $permission);
}
}

View File

@@ -4,30 +4,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
/**
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
public function dashboard(): Response
{
$user = $this->user();
$organization = $this->currentOrganization();
$latestTeamActivity = null;
if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
}
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return Inertia::render('Dashboard');
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\Organization;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class OrganizationController extends Controller
{
/**
* Show the team creation screen.
*/
public function create(Request $request): Response
{
return Inertia::render('Teams/Create');
}
/**
* Show the organizatio details screen.
*
* @param string $organizationId The organization ID
*/
public function show(string $organizationId): Response|RedirectResponse
{
$organization = Str::isUuid($organizationId) ? Organization::find($organizationId) : null;
if ($organization === null) {
return redirect()->route('dashboard');
}
if (! $this->hasPermission($organization, 'organizations:view')) {
return redirect()->route('dashboard');
}
$owner = $organization->owner;
return Inertia::render('Teams/Show', [
'team' => [
'id' => $organization->getKey(),
'name' => $organization->name,
'currency' => $organization->currency,
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'profile_photo_url' => $owner->profile_photo_url,
],
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
'permissions' => [
'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'),
'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'),
],
]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use RuntimeException;
class OrganizationInvitationController extends Controller
{
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
{
$email = strtolower($invitation->email);
$role = Role::tryFrom($invitation->role);
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
throw new RuntimeException('Invalid role');
}
$organization = $invitation->organization;
$invitee = User::query()
->where('email', $email)
->where('is_placeholder', '=', false)
->first();
// No account yet — finish on registration.
if ($invitee === null) {
if ($invitation->accepted_at === null) {
$invitation->accepted_at = now();
$invitation->save();
}
return redirect(route('register'))
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'info');
}
$alreadyMember = $memberService->isEmailAlreadyMember($organization, $email);
if (! $alreadyMember) {
$memberService->addMember($invitee, $organization, $role);
$invitation->delete();
}
// Logged out — banner on /login.
if (! Auth::check()) {
return redirect(route('login'))
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization. Please log in to access it.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'success');
}
// Logged in — banner on /dashboard.
if ($alreadyMember) {
return redirect(route('dashboard'))
->with('bannerText', __('You are already a member of the :organization organization.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'danger');
}
return redirect(route('dashboard'))
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization.', [
'organization' => $organization->name,
]))
->with('bannerStyle', 'success');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmPassword;
class OtherBrowserSessionsController extends Controller
{
/**
* Log the user out of their other browser sessions across all devices.
*/
public function destroy(Request $request, StatefulGuard $guard): RedirectResponse
{
$password = (string) $request->string('password');
$confirmed = app(ConfirmPassword::class)($guard, $request->user(), $password);
if (! $confirmed) {
throw ValidationException::withMessages([
'password' => __('The password is incorrect.'),
]);
}
$guard->logoutOtherDevices($password);
$this->deleteOtherSessionRecords($request);
return back(303);
}
/**
* Delete the other browser session records from storage.
*/
protected function deleteOtherSessionRecords(Request $request): void
{
if (config('session.driver') !== 'database') {
return;
}
DB::connection(config('session.connection'))
->table(config('session.table', 'sessions'))
->where('user_id', $request->user()->getAuthIdentifier())
->where('id', '!=', $request->session()->getId())
->delete();
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class UserController extends Controller
{
public function verifyEmailChange(Request $request, User $user): RedirectResponse
{
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
abort(403);
}
$email = $request->query('email');
if (! is_string($email)) {
abort(403);
}
$email = Str::lower($email);
if ($user->pending_email !== $email) {
abort(403);
}
$emailAlreadyInUse = User::query()
->where('email', '=', $email)
->where('is_placeholder', '=', false)
->whereKeyNot($user->getKey())
->exists();
if ($emailAlreadyInUse) {
return redirect(route('dashboard'))
->with('bannerStyle', 'danger')
->with('bannerText', __('The email address is already in use.'));
}
$user->email = $email;
$user->pending_email = null;
$user->email_verified_at = Carbon::now();
$user->save();
return redirect(route('dashboard'))
->with('bannerStyle', 'success')
->with('bannerText', __('Your email address has been updated successfully.'));
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Weekday;
use App\Service\Dto\UserAgentDto;
use App\Service\TimezoneService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;
class UserProfileController extends Controller
{
/**
* Validate the two-factor authentication state for the request.
*/
protected function validateTwoFactorAuthenticationState(Request $request): void
{
if (! Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
return;
}
$currentTime = time();
// Notate totally disabled state in session...
if ($this->twoFactorAuthenticationDisabled($request)) {
$request->session()->put('two_factor_empty_at', $currentTime);
}
// If was previously totally disabled this session but is now confirming, notate time...
if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) {
$request->session()->put('two_factor_confirming_at', $currentTime);
}
// If the profile is reloaded and is not confirmed but was previously in confirming state, disable...
if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) {
app(DisableTwoFactorAuthentication::class)(Auth::user());
$request->session()->put('two_factor_empty_at', $currentTime);
$request->session()->remove('two_factor_confirming_at');
}
}
/**
* Determine if two-factor authentication is totally disabled.
*
* @return bool
*/
protected function twoFactorAuthenticationDisabled(Request $request)
{
return is_null($request->user()->two_factor_secret) &&
is_null($request->user()->two_factor_confirmed_at);
}
/**
* Determine if two-factor authentication is just now being confirmed within the last request cycle.
*
* @return bool
*/
protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request)
{
return ! is_null($request->user()->two_factor_secret) &&
is_null($request->user()->two_factor_confirmed_at) &&
$request->session()->has('two_factor_empty_at') &&
is_null($request->session()->get('two_factor_confirming_at'));
}
/**
* Determine if two-factor authentication was never totally confirmed once confirmation started.
*
* @return bool
*/
protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, int $currentTime)
{
return ! array_key_exists('code', $request->session()->getOldInput()) &&
is_null($request->user()->two_factor_confirmed_at) &&
$request->session()->get('two_factor_confirming_at', 0) !== $currentTime;
}
/**
* Show the general profile settings screen.
*/
public function show(Request $request): Response
{
$this->validateTwoFactorAuthenticationState($request);
return Inertia::render('Profile/Show', [
'timezones' => app(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
'sessions' => $this->sessions($request),
]);
}
/**
* Get the current sessions.
*
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&\stdClass>
*/
public function sessions(Request $request): array
{
if (config('session.driver') !== 'database') {
return [];
}
return collect(
DB::connection(config('session.connection'))->table(config('session.table', 'sessions'))
->where('user_id', $request->user()->getAuthIdentifier())
->orderBy('last_activity', 'desc')
->get()
)->map(function (object $session) use ($request): object {
$agent = $this->createAgent(is_string($session->user_agent) ? $session->user_agent : '');
return (object) [
'agent' => [
'is_desktop' => $agent->isDesktop(),
'platform' => $agent->platform(),
'browser' => $agent->browser(),
],
'ip_address' => is_string($session->ip_address) ? $session->ip_address : '',
'is_current_device' => $session->id === $request->session()->getId(),
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
];
})->all();
}
/**
* Create a new agent instance from the given session.
*/
protected function createAgent(string $userAgent): UserAgentDto
{
return tap(new UserAgentDto, fn ($agent) => $agent->setUserAgent($userAgent));
}
}

View File

@@ -4,9 +4,38 @@ declare(strict_types=1);
namespace App\Http;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\CheckOrganizationBlocked;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureEmailIsVerified;
use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleInertiaRequests;
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;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Laravel\Passport\Http\Middleware\CreateFreshApiToken;
class Kernel extends HttpKernel
{
@@ -18,13 +47,14 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
\App\Http\Middleware\ForceHttps::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
ForceHttps::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
];
/**
@@ -34,21 +64,21 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HandleInertiaRequests::class,
\App\Http\Middleware\ShareInertiaData::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
HandleInertiaRequests::class,
ShareInertiaData::class,
AddLinkHeadersForPreloadedAssets::class,
CreateFreshApiToken::class,
],
'api' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
ThrottleRequests::class.':api',
SubstituteBindings::class,
ForceJsonResponse::class,
],
@@ -64,17 +94,17 @@ class Kernel extends HttpKernel
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
'auth' => Authenticate::class,
'auth.basic' => AuthenticateWithBasicAuth::class,
'auth.session' => AuthenticateSession::class,
'cache.headers' => SetCacheHeaders::class,
'can' => Authorize::class,
'guest' => RedirectIfAuthenticated::class,
'password.confirm' => RequirePassword::class,
'precognitive' => HandlePrecognitiveRequests::class,
'signed' => ValidateSignature::class,
'throttle' => ThrottleRequests::class,
'verified' => EnsureEmailIsVerified::class,
'check-organization-blocked' => CheckOrganizationBlocked::class,
];
}

View File

@@ -17,7 +17,7 @@ class EnsureEmailIsVerified
*/
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response
{
if (! app()->isLocal()) {
if (! app()->isLocal() || config('app.local_email_verification')) {
if ($request->user() === null ||
(! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()

View File

@@ -14,7 +14,7 @@ class ForceHttps
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{

View File

@@ -13,7 +13,7 @@ class ForceJsonResponse
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{

View File

@@ -46,7 +46,7 @@ class HandleInertiaRequests extends Middleware
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
$currentOrganization = $request->user()?->currentTeam;
$currentOrganization = $request->user()?->currentOrganization;
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),
'bannerText' => fn () => $request->session()->get('bannerText'),
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
],
]);
}

View File

@@ -15,7 +15,7 @@ class RedirectIfAuthenticated
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{

View File

@@ -9,12 +9,10 @@ use App\Models\User;
use App\Service\PermissionStore;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\MessageBag;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Symfony\Component\HttpFoundation\Response;
class ShareInertiaData
@@ -27,28 +25,8 @@ class ShareInertiaData
/** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class);
Inertia::share([
'jetstream' => function () use ($request) {
/** @var User|null $user */
$user = $request->user();
return [
'canCreateTeams' => $user !== null &&
Jetstream::userHasTeamFeatures($user) &&
Gate::forUser($user)->check('create', Jetstream::newTeamModel()),
'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(),
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
'flash' => $request->session()->get('flash', []),
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
'hasApiFeatures' => Jetstream::hasApiFeatures(),
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
'managesProfilePhotos' => Jetstream::managesProfilePhotos(),
];
},
'auth' => [
'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],
'permissions' => $request->user() !== null && $request->user()->currentOrganization !== null ? $permissions->getPermissions($request->user()->currentOrganization) : [],
'user' => function () use ($request): array {
/** @var User|null $user */
$user = $request->user();
@@ -57,6 +35,8 @@ class ShareInertiaData
return [];
}
$currentOrganization = $user->currentOrganization;
return array_merge([
'id' => $user->id,
'name' => $user->name,
@@ -69,12 +49,12 @@ class ShareInertiaData
'profile_photo_url' => $user->profile_photo_url,
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
&& ! is_null($user->two_factor_secret),
'current_team' => $user->currentTeam !== null ? [
'id' => $user->currentTeam->id,
'user_id' => $user->currentTeam->user_id,
'name' => $user->currentTeam->name,
'personal_team' => $user->currentTeam->personal_team,
'currency' => $user->currentTeam->currency,
'current_team' => $currentOrganization !== null ? [
'id' => $currentOrganization->id,
'user_id' => $currentOrganization->user_id,
'name' => $currentOrganization->name,
'personal_team' => $currentOrganization->personal_team,
'currency' => $currentOrganization->currency,
] : null,
], array_filter([
'all_teams' => $user->organizations->map(function (Organization $organization): array {

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -19,7 +20,7 @@ class MemberMergeIntoRequest 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|Rule>>
*/
public function rules(): array
{

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Validator;
class OrganizationDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{
return [
'password' => [
'required',
'string',
],
];
}
/**
* @return array<int, callable(Validator): void>
*/
public function after(): array
{
return [
function (Validator $validator): void {
if ($validator->errors()->has('password')) {
return;
}
$user = $this->user();
$password = $this->input('password');
if (! is_string($password) || $user === null || ! Hash::check($password, (string) $user->password)) {
$validator->errors()->add('password', __('The password is incorrect.'));
}
},
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|Rule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
}

View File

@@ -11,6 +11,8 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Rules\CurrencyRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
/**
@@ -21,7 +23,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
*/
public function rules(): array
{
@@ -30,6 +32,10 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string',
'max:255',
],
'currency' => [
'string',
new CurrencyRule,
],
'billable_rate' => array_merge(
[
'nullable',
@@ -45,6 +51,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
'prevent_overlapping_time_entries' => [
'boolean',
],
'breaks_enabled' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -68,6 +77,11 @@ class OrganizationUpdateRequest extends BaseFormRequest
return $this->has('name') ? (string) $this->input('name') : null;
}
public function getCurrency(): ?string
{
return $this->has('currency') ? (string) $this->input('currency') : null;
}
public function getNumberFormat(): ?NumberFormat
{
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;
@@ -114,4 +128,9 @@ 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,9 +4,11 @@ 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;
@@ -124,6 +126,11 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [
'nullable',
'array',
@@ -171,6 +178,12 @@ class ReportStoreRequest extends BaseFormRequest
'numeric',
'integer',
],
// Filter by time entry type
'properties.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
@@ -234,6 +247,15 @@ 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'));
@@ -249,6 +271,15 @@ 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,9 +5,11 @@ 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;
@@ -139,6 +141,10 @@ 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',
@@ -178,6 +184,11 @@ 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',
@@ -246,6 +257,15 @@ 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,8 +4,10 @@ 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;
@@ -125,6 +127,10 @@ 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',
@@ -164,6 +170,11 @@ 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',
@@ -208,6 +219,15 @@ 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,7 +5,9 @@ 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;
@@ -110,6 +112,10 @@ 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',
@@ -150,6 +156,11 @@ 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',
@@ -215,6 +226,15 @@ 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,7 +4,9 @@ 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;
@@ -103,6 +105,10 @@ 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',
@@ -143,6 +149,11 @@ 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',
@@ -190,6 +201,15 @@ 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,6 +4,7 @@ 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;
@@ -14,6 +15,7 @@ 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;
/**
@@ -24,7 +26,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
@@ -42,6 +44,7 @@ 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');
@@ -60,6 +63,7 @@ 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');
@@ -85,6 +89,16 @@ 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' => [
@@ -96,6 +110,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'tags' => [
'nullable',
'array',
'prohibited_if:type,break',
],
'tags.*' => [
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {

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