Compare commits

..

103 Commits

Author SHA1 Message Date
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
240 changed files with 12851 additions and 2659 deletions

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -46,9 +46,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
@@ -96,7 +96,7 @@ jobs:
node-version: '20.x'
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
@@ -124,27 +124,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 +163,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/*
@@ -177,25 +177,25 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v6
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

@@ -22,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -33,9 +33,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
@@ -73,7 +73,7 @@ jobs:
node-version: '20.x'
- name: "Checkout billing extension"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-billing
path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-services
path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
@@ -160,7 +160,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 +168,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 +179,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

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -47,9 +47,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
@@ -109,34 +109,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 +155,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/*
@@ -169,31 +169,31 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v6
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

@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- 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

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v6

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v6

View File

@@ -11,7 +11,7 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci

View File

@@ -11,7 +11,7 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v6
with:

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: ubuntu-latest
timeout-minutes: 10
env:
TZ: UTC
steps:
- name: "Checkout code"
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Run vitest"
run: npm run test:unit

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -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.5.1
uses: codecov/codecov-action@v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

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

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v6
@@ -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 }}
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
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/

1
.npmrc Normal file
View File

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

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,16 +4,9 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Mail\VerifyUpdatedEmailMail;
use App\Exceptions\MovedToApiException;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -27,61 +20,6 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/
public function update(User $user, array $input): void
{
if (isset($input['email']) && is_string($input['email'])) {
$input['email'] = Str::lower($input['email']);
}
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']);
}
$email = Str::lower((string) $input['email']);
if ($email !== Str::lower($user->email)) {
$user->forceFill([
'name' => $input['name'],
'pending_email' => $email,
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save();
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
} else {
$user->forceFill([
'name' => $input['name'],
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save();
}
throw new MovedToApiException;
}
}

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
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
{
throw new MovedToApiException;
}
}

View File

@@ -1,60 +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 App\Service\UserService;
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
*
* @deprecated Use REST endpoint instead
*/
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
);
app(UserService::class)->switchCurrentOrganization($user, $organization);
AfterCreateOrganization::dispatch($organization);
return $organization;
}
}

View File

@@ -1,23 +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.
*
* @deprecated Use REST endpoint instead
*/
public function delete(Organization $organization): void
{
/** @see ValidateOrganizationDeletion */
app(DeletionService::class)->deleteOrganization($organization);
}
}

View File

@@ -1,32 +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
*
* @deprecated Use REST endpoint instead
*/
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,30 +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
*
* @deprecated Use REST endpoint instead
*/
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException;
}
}
}

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

@@ -9,6 +9,9 @@ use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy TeamMemberAdded event.
*/
class MemberAdded
{
use Dispatchable;

View File

@@ -9,6 +9,9 @@ use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Replaces legacy AddingTeamMember event.
*/
class MemberAdding
{
use Dispatchable;

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

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

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

@@ -42,6 +42,7 @@ class InvitationController extends Controller
$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

@@ -6,6 +6,7 @@ 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;
@@ -50,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();
}
@@ -121,7 +125,7 @@ class OrganizationController extends Controller
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse
{
$this->checkPermission($organization, 'organizations:delete');

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

@@ -59,7 +59,7 @@ class ReportController extends Controller
$filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$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;

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

@@ -194,7 +194,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,7 +204,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$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'));
@@ -559,7 +560,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$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'));

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

@@ -6,11 +6,15 @@ 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;
@@ -36,6 +40,35 @@ 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
*
@@ -50,7 +83,7 @@ class UserController extends Controller
}
if ($request->hasPhotoKey()) {
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$photoDisk = (string) config('filesystems.public');
$previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto();
@@ -161,7 +194,7 @@ class UserController extends Controller
* @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, DeletionService $deletionService): JsonResponse
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;

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

@@ -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,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,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

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

@@ -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,27 +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()),
'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();
@@ -56,6 +35,8 @@ class ShareInertiaData
return [];
}
$currentOrganization = $user->currentOrganization;
return array_merge([
'id' => $user->id,
'name' => $user->name,
@@ -68,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,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

@@ -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',
@@ -68,6 +74,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;

View File

@@ -4,6 +4,7 @@ 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;
@@ -124,6 +125,11 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [
'nullable',
'array',
@@ -249,6 +255,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,6 +5,7 @@ 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;
@@ -139,6 +140,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',
@@ -246,6 +251,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,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
@@ -125,6 +126,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',
@@ -208,6 +213,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,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Models\Client;
use App\Models\Member;
@@ -110,6 +111,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',
@@ -215,6 +220,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,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
@@ -103,6 +104,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',
@@ -190,6 +195,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

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Validator;
class UserDestroyRequest 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,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class UserUpdateCurrentOrganizationRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'organization_id' => [
'required',
'string',
'uuid',
],
];
}
public function getOrganizationId(): string
{
return (string) $this->input('organization_id');
}
}

View File

@@ -56,6 +56,8 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -9,10 +9,11 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
use Laravel\Jetstream\Membership as JetstreamMembership;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -30,7 +31,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*
* @method static MemberFactory factory()
*/
class Member extends JetstreamMembership implements AuditableContract
class Member extends Pivot implements AuditableContract
{
use CustomAuditable;

View File

@@ -14,6 +14,7 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -21,11 +22,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Team;
use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -53,7 +49,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam implements AuditableContract
class Organization extends Model implements AuditableContract
{
use CustomAuditable;
@@ -91,17 +87,6 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team',
];
/**
* The event map for the model.
*
* @var array<string, class-string>
*/
protected $dispatchesEvents = [
'created' => TeamCreated::class,
'updated' => TeamUpdated::class,
'deleted' => TeamDeleted::class,
];
/**
* The model's default values for attributes.
*
@@ -163,12 +148,13 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* This method prevents an unhandled exception when the ID is not a UUID.
* Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.
* Find a model by its primary key or throw an exception.
*
* @param array<string> $columns
* @param array<int, string> $columns
*
* @throws ModelNotFoundException<Model>
*/
public function findOrFail(string $id, array $columns = ['*']): Team
public static function findOrFail(string $id, array $columns = ['*']): Model
{
if (! Str::isUuid($id)) {
throw (new ModelNotFoundException)->setModel(

View File

@@ -8,9 +8,9 @@ use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationInvitationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -25,7 +25,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*
* @method static OrganizationInvitationFactory factory()
*/
class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
class OrganizationInvitation extends Model implements AuditableContract
{
use CustomAuditable;

View File

@@ -26,8 +26,6 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
@@ -46,7 +44,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Weekday $week_start
* @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url
* @property-read Collection<int, Token> $tokens
* @property Carbon|null $created_at
@@ -71,8 +68,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** @use HasFactory<UserFactory> */
use HasFactory;
use HasProfilePhoto;
use HasTeams;
use HasUuids;
use Notifiable;
use TwoFactorAuthenticatable;

View File

@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization;
class OrganizationPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
if (Filament::isServing()) {
return true;
}
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->isMemberOfOrganization($organization);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
if (Filament::isServing()) {
return true;
}
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
}
/**
* Determine whether the user can update team member permissions.
*/
public function updateTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
// Note: since this policy is only used for jetstream endpoints, we can return false here
return false;
}
/**
* Determine whether the user can remove team members.
*/
public function removeTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
// Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here
return false;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete');
}
}

View File

@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Jetstream\Jetstream;
use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider
@@ -22,7 +19,6 @@ class AuthServiceProvider extends ServiceProvider
* @var array<class-string, class-string>
*/
protected $policies = [
Organization::class => OrganizationPolicy::class,
];
/**
@@ -56,11 +52,5 @@ class AuthServiceProvider extends ServiceProvider
// Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30));
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
// same as passport default above
Jetstream::defaultApiTokenPermissions(['read']);
// use passport scopes for jetstream token permissions
Jetstream::permissions(Passport::scopeIds());
}
}

View File

@@ -15,12 +15,13 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Responses\LoginResponse;
class FortifyServiceProvider extends ServiceProvider
{
@@ -50,6 +51,40 @@ class FortifyServiceProvider extends ServiceProvider
]);
});
Fortify::loginView(function () {
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
});
Fortify::requestPasswordResetLinkView(function () {
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
});
Fortify::resetPasswordView(function (Request $request) {
return Inertia::render('Auth/ResetPassword', [
'email' => $request->input('email'),
'token' => $request->route('token'),
]);
});
Fortify::verifyEmailView(function () {
return Inertia::render('Auth/VerifyEmail', [
'status' => session('status'),
]);
});
Fortify::twoFactorChallengeView(function () {
return Inertia::render('Auth/TwoFactorChallenge');
});
Fortify::confirmPasswordView(function () {
return Inertia::render('Auth/ConfirmPassword');
});
Fortify::authenticateUsing(function (Request $request): ?User {
/** @var User|null $user */
$user = User::query()
@@ -74,7 +109,7 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
$this->app->instance(LoginResponse::class, new CustomLoginResponse);
$this->app->instance(LoginResponseContract::class, new CustomLoginResponse);
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
}
}

View File

@@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Jetstream\AddOrganizationMember;
use App\Actions\Jetstream\CreateOrganization;
use App\Actions\Jetstream\DeleteOrganization;
use App\Actions\Jetstream\DeleteUser;
use App\Actions\Jetstream\InviteOrganizationMember;
use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Weekday;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\PermissionStore;
use App\Service\TimezoneService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configurePermissions();
Jetstream::createTeamsUsing(CreateOrganization::class);
Jetstream::updateTeamNamesUsing(UpdateOrganization::class);
Jetstream::addTeamMembersUsing(AddOrganizationMember::class);
Jetstream::inviteTeamMembersUsing(InviteOrganizationMember::class);
Jetstream::removeTeamMembersUsing(RemoveOrganizationMember::class);
Jetstream::deleteTeamsUsing(DeleteOrganization::class);
Jetstream::deleteUsersUsing(DeleteUser::class);
Jetstream::useTeamModel(Organization::class);
Jetstream::useMembershipModel(Member::class);
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Gate::define('removeTeamMember', function (User $user, Organization $team) {
return false;
});
}
/**
* Configure the roles and permissions that are available within the application.
*/
protected function configurePermissions(): void
{
Jetstream::defaultApiTokenPermissions([]);
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
Jetstream::role($role, $definition['name'], $definition['permissions'])
->description($definition['description']);
}
Jetstream::inertia()
->whenRendering(
'Profile/Show',
function (Request $request, array $data): array {
return array_merge($data, [
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
]);
}
)
->whenRendering(
'Teams/Show',
function (Request $request, array $data): array {
/** @var Organization $teamModel */
$teamModel = $data['team'];
$owner = $teamModel->owner;
return array_merge($data, [
'team' => [
'id' => $teamModel->getKey(),
'name' => $teamModel->name,
'currency' => $teamModel->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()),
]);
}
);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -56,6 +57,8 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
@@ -115,6 +118,7 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -144,6 +148,7 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
@@ -216,6 +221,11 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
public function setTagMatchType(?TagMatchType $tagMatchType): void
{
$this->tagMatchType = $tagMatchType;
}
/**
* @param array<mixed>|null $taskIds
*/

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use Closure;
use Detection\MobileDetect;
/**
* @copyright Originally created by Jens Segers: https://github.com/jenssegers/agent
*/
class UserAgentDto extends MobileDetect
{
/**
* List of additional operating systems.
*
* @var array<string, string>
*/
protected static array $additionalOperatingSystems = [
'Windows' => 'Windows',
'Windows NT' => 'Windows NT',
'OS X' => 'Mac OS X',
'Debian' => 'Debian',
'Ubuntu' => 'Ubuntu',
'Macintosh' => 'PPC',
'OpenBSD' => 'OpenBSD',
'Linux' => 'Linux',
'ChromeOS' => 'CrOS',
];
/**
* List of additional browsers.
*
* @var array<string, string>
*/
protected static array $additionalBrowsers = [
'Opera Mini' => 'Opera Mini',
'Opera' => 'Opera|OPR',
'Edge' => 'Edge|Edg',
'Coc Coc' => 'coc_coc_browser',
'UCBrowser' => 'UCBrowser',
'Vivaldi' => 'Vivaldi',
'Chrome' => 'Chrome',
'Firefox' => 'Firefox',
'Safari' => 'Safari',
'IE' => 'MSIE|IEMobile|MSIEMobile|Trident/[.0-9]+',
'Netscape' => 'Netscape',
'Mozilla' => 'Mozilla',
'WeChat' => 'MicroMessenger',
];
/**
* Key value store for resolved strings.
*
* @var array<string, mixed>
*/
protected array $store = [];
/**
* Get the platform name from the User Agent.
*/
public function platform(): ?string
{
return $this->retrieveUsingCacheOrResolve('platform', function () {
return $this->findDetectionRulesAgainstUserAgent(
$this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems)
);
});
}
/**
* Get the browser name from the User Agent.
*/
public function browser(): ?string
{
return $this->retrieveUsingCacheOrResolve('browser', function (): ?string {
return $this->findDetectionRulesAgainstUserAgent(
$this->mergeRules(static::$additionalBrowsers, MobileDetect::getBrowsers())
);
});
}
/**
* Determine if the device is a desktop computer.
*/
public function isDesktop(): bool
{
return $this->retrieveUsingCacheOrResolve('desktop', function (): bool {
// Check specifically for cloudfront headers if the useragent === 'Amazon CloudFront'
if (
$this->getUserAgent() === static::$cloudFrontUA
&& $this->getHttpHeader('HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER') === 'true'
) {
return true;
}
return ! $this->isMobile() && ! $this->isTablet();
});
}
/**
* Match a detection rule and return the matched key.
*
* @param array<string, string|list<string>> $rules
*/
protected function findDetectionRulesAgainstUserAgent(array $rules): ?string
{
$userAgent = $this->getUserAgent();
foreach ($rules as $key => $regex) {
if (is_array($regex)) {
$regex = implode('|', $regex);
}
if (empty($regex)) {
continue;
}
if ($this->match($regex, $userAgent)) {
if ($key !== '') {
return $key;
}
$match = reset($this->matchesArray);
return is_string($match) ? $match : null;
}
}
return null;
}
/**
* Retrieve from the given key from the cache or resolve the value.
*
* @template TReturn of string|bool|null
*
* @param Closure():TReturn $callback
* @return TReturn
*/
protected function retrieveUsingCacheOrResolve(string $key, Closure $callback): string|bool|null
{
$cacheKey = $this->createCacheKey($key);
if (! is_null($cacheItem = $this->store[$cacheKey] ?? null)) {
return $cacheItem;
}
return tap(call_user_func($callback), function ($result) use ($cacheKey): void {
$this->store[$cacheKey] = $result;
});
}
/**
* Merge multiple rules into one array.
*
* @param array<string, string|list<string>> ...$all
* @return array<string, string>
*/
protected function mergeRules(array ...$all): array
{
$merged = [];
foreach ($all as $rules) {
foreach ($rules as $key => $value) {
$value = is_array($value) ? implode('|', $value) : $value;
if (empty($merged[$key])) {
$merged[$key] = $value;
} else {
$merged[$key] .= '|'.$value;
}
}
}
return $merged;
}
}

View File

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

View File

@@ -54,6 +54,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
@@ -71,7 +72,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
if (($record['Client'] ?? '') !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -96,9 +97,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$taskId = null;
if ($record['Task'] !== '') {
if ($taskKey !== null && $record[$taskKey] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'],
'name' => $record[$taskKey],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
@@ -116,10 +117,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $record['Description'];
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
if (isset($record['Billable'])) {
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
@@ -212,14 +215,11 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Description',
'Task',
'User',
'Group',
'Email',
'Tags',
'Billable',
'Start Date',
'Start Time',
'End Date',
@@ -230,6 +230,26 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
#[\Override]

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\OrganizationInvitationAdding;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail;
@@ -14,14 +15,13 @@ use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService
{
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
{
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException;
@@ -34,7 +34,7 @@ class InvitationService
throw new InvitationForTheEmailAlreadyExistsApiException;
}
InvitingTeamMember::dispatch($organization, $email, $role->value);
OrganizationInvitationAdding::dispatch($organization, $email, $role, $inviter);
$invitation = new OrganizationInvitation;
$invitation->email = $email;

View File

@@ -23,8 +23,6 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class MemberService
{
@@ -39,7 +37,6 @@ class MemberService
{
if (! $asSuperAdmin) {
MemberAdding::dispatch($user, $organization, $role);
AddingTeamMember::dispatch($organization, $user); // Legacy event
}
$member = new Member;
@@ -56,7 +53,6 @@ class MemberService
if (! $asSuperAdmin) {
MemberAdded::dispatch($member, $organization, $user);
TeamMemberAdded::dispatch($organization, $user); // Legacy event
}
return $member;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\TagMatchType;
use App\Models\Member;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
@@ -62,7 +63,7 @@ class TimeEntryFilter
if ($start === null) {
return $this;
}
$this->builder->where('start', '>', $start);
$this->builder->where('start', '>=', $start);
return $this;
}
@@ -192,15 +193,21 @@ class TimeEntryFilter
/**
* @param array<string>|null $tagIds
*/
public function addTagIdsFilter(?array $tagIds): self
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
{
if ($tagIds === null) {
return $this;
}
$tagMatchType ??= TagMatchType::Contains;
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
if (count($tagIds) === 0 && ! $includeNone) {
return $this;
}
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
@@ -209,7 +216,18 @@ class TimeEntryFilter
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
});
}
});
};
if ($tagMatchType === TagMatchType::NotContains) {
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
$builder->whereNot($tagCondition);
if (! $includeNone) {
$builder->orWhereNull('tags');
}
});
} else {
$this->builder->where($tagCondition);
}
return $this;
}

View File

@@ -62,6 +62,7 @@ class UserService
$intervalFormat,
$timeFormat,
);
$this->switchCurrentOrganization($user, $organization);
}
return $user;

View File

@@ -18,8 +18,8 @@
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
"laravel/fortify": "^1.37",
"laravel/framework": "^12.19.3",
"laravel/jetstream": "^5.0",
"laravel/octane": "^2.3",
"laravel/passport": "^13.0.5",
"laravel/tinker": "^2.8",
@@ -27,6 +27,7 @@
"league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1",
"mobiledetect/mobiledetectlib": "^4.11",
"novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^14.0.0",
@@ -131,7 +132,8 @@
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"wikimedia/composer-merge-plugin": true
}
},
"process-timeout": 900
},
"minimum-stability": "stable",
"prefer-stable": true

92
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4c728f01d2beb426b2d157143618fdae",
"content-hash": "897ca7bc13f827db641f7affa54a8523",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -4413,72 +4413,6 @@
},
"time": "2026-05-20T11:48:19+00:00"
},
{
"name": "laravel/jetstream",
"version": "v5.5.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/jetstream.git",
"reference": "61cac5cde455311890f6981fb2da47acd298e4e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/jetstream/zipball/61cac5cde455311890f6981fb2da47acd298e4e2",
"reference": "61cac5cde455311890f6981fb2da47acd298e4e2",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"laravel/fortify": "^1.20",
"mobiledetect/mobiledetectlib": "^4.8.08",
"php": "^8.2.0",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"inertiajs/inertia-laravel": "^2.0",
"laravel/sanctum": "^4.0",
"livewire/livewire": "^3.3",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Jetstream\\JetstreamServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Jetstream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Tailwind scaffolding for the Laravel framework.",
"keywords": [
"auth",
"laravel",
"tailwind"
],
"support": {
"issues": "https://github.com/laravel/jetstream/issues",
"source": "https://github.com/laravel/jetstream"
},
"time": "2026-05-19T01:30:03+00:00"
},
{
"name": "laravel/octane",
"version": "v2.17.4",
@@ -6445,16 +6379,16 @@
},
{
"name": "mobiledetect/mobiledetectlib",
"version": "4.10.0",
"version": "4.11.0",
"source": {
"type": "git",
"url": "https://github.com/serbanghita/Mobile-Detect.git",
"reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c"
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/ab39168b7556f44c11c80be1222b44b239f5c2e4",
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4",
"shasum": ""
},
"require": {
@@ -6497,7 +6431,7 @@
],
"support": {
"issues": "https://github.com/serbanghita/Mobile-Detect/issues",
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.10.0"
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.11.0"
},
"funding": [
{
@@ -6505,7 +6439,7 @@
"type": "github"
}
],
"time": "2026-04-23T13:05:57+00:00"
"time": "2026-05-24T12:32:40+00:00"
},
{
"name": "monolog/monolog",
@@ -13803,16 +13737,16 @@
},
{
"name": "web-auth/webauthn-lib",
"version": "5.3.3",
"version": "5.3.5",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df"
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f",
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f",
"shasum": ""
},
"require": {
@@ -13873,7 +13807,7 @@
"webauthn"
],
"support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3"
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5"
},
"funding": [
{
@@ -13885,7 +13819,7 @@
"type": "patreon"
}
],
"time": "2026-05-17T19:04:30+00:00"
"time": "2026-05-31T15:00:08+00:00"
},
{
"name": "webmozart/assert",

View File

@@ -12,7 +12,6 @@ use App\Providers\AuthServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\Filament\AdminPanelProvider;
use App\Providers\FortifyServiceProvider;
use App\Providers\JetstreamServiceProvider;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
@@ -82,6 +81,8 @@ return [
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
/*
|--------------------------------------------------------------------------
| Application Timezone
@@ -203,7 +204,6 @@ return [
AdminPanelProvider::class,
RouteServiceProvider::class,
FortifyServiceProvider::class,
JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(),

View File

@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
use Laravel\Jetstream\Features;
use Laravel\Jetstream\Http\Middleware\AuthenticateSession;
return [
/*
|--------------------------------------------------------------------------
| Jetstream Stack
|--------------------------------------------------------------------------
|
| This configuration value informs Jetstream which "stack" you will be
| using for your application. In general, this value is set for you
| during installation and will not need to be changed after that.
|
*/
'stack' => 'inertia',
/*
|--------------------------------------------------------------------------
| Jetstream Route Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Jetstream will assign to the routes
| that it registers with the application. When necessary, you may modify
| these middleware; however, this default value is usually sufficient.
|
*/
'middleware' => ['web'],
'auth_session' => AuthenticateSession::class,
/*
|--------------------------------------------------------------------------
| Jetstream Guard
|--------------------------------------------------------------------------
|
| Here you may specify the authentication guard Jetstream will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of Jetstream's features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::termsAndPrivacyPolicy(),
Features::profilePhotos(),
Features::teams(['invitations' => true]),
Features::accountDeletion(),
],
/*
|--------------------------------------------------------------------------
| Profile Photo Disk
|--------------------------------------------------------------------------
|
| This configuration value determines the default disk that will be used
| when storing profile photos for your application's users. Typically
| this will be the "public" disk but you may adjust this if needed.
|
*/
'profile_photo_disk' => env('PROFILE_PHOTO_DISK', env('PUBLIC_FILESYSTEM_DISK', 'public')),
];

View File

@@ -94,7 +94,7 @@ class UserFactory extends Factory
$profilePhoto = $this->faker->image(null, 500, 500);
/** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
return $this->state(function (array $attributes) use ($path): array {
return [

View File

@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.58.1-jammy
image: mcr.microsoft.com/playwright:v1.60.0-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -12,7 +12,7 @@ import {
createRunningTimeEntryWithStartViaApi,
createTaskViaApi,
createProjectWithClientViaApi,
updateUserProfileViaWeb,
updateUserProfileViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
@@ -1803,28 +1803,22 @@ test.describe('Click-Drag Selection to Create', () => {
// =============================================
test.describe('Timezone & Localization', () => {
test('week start day: monday shows Mon as first column', async ({ page }) => {
// Navigate to calendar first to load Inertia page props
test('week start day: monday shows Mon as first column', async ({ page, ctx }) => {
await updateUserProfileViaApi(ctx, { week_start: 'monday' });
await goToCalendar(page);
await updateUserProfileViaWeb(page, { week_start: 'monday' });
await page.reload();
await expect(page.locator('.fc')).toBeVisible();
const firstHeader = page.locator('.fc-col-header-cell').first();
await expect(firstHeader).toContainText('Mon');
});
test('week start day: sunday shows Sun as first column', async ({ page }) => {
test('week start day: sunday shows Sun as first column', async ({ page, ctx }) => {
await updateUserProfileViaApi(ctx, { week_start: 'sunday' });
await goToCalendar(page);
await updateUserProfileViaWeb(page, { week_start: 'sunday' });
await page.reload();
await expect(page.locator('.fc')).toBeVisible();
const firstHeader = page.locator('.fc-col-header-cell').first();
await expect(firstHeader).toContainText('Sun');
// Reset to monday for other tests
await updateUserProfileViaWeb(page, { week_start: 'monday' });
});
test('12-hour time format shows AM/PM on slot labels', async ({ page, ctx }) => {

View File

@@ -246,7 +246,7 @@ test('test that sorting clients by name and status works', async ({ page, ctx })
test('test that sorting clients by project count works', async ({ page, ctx }) => {
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
await createClientViaApi(ctx, { name: 'NoProjects Client' });
// Create projects for the first client
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
@@ -374,3 +374,119 @@ test.describe('Employee Clients Restrictions', () => {
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
});
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Clients Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that client table paginates when there are more than 15 clients', async ({
page,
ctx,
}) => {
// Create 17 clients with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 0014, page 2 shows 1516.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
// Default sort is name asc; first 15 clients (0014) on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Client 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Navigation to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 clients.
await expect(page.getByRole('row')).toHaveCount(2);
// Back to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// First / Last page jumps.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation + selected state.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that client pagination is not shown when there are 15 or fewer clients', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createClientViaApi(ctx, {
name: `FewClient ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets client pagination to page 1', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending.
const table = page.getByTestId('client_table');
const nameHeader = table.getByText('Name').first();
await nameHeader.click();
// Pagination reset to page 1; desc order → 16, 15 visible, 00 on page 2.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});

View File

@@ -348,7 +348,7 @@ test.describe('Command Palette', () => {
const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000);
// Create a new organization
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create');
await page.getByLabel('Organization Name').fill(newOrgName);
await page.getByRole('button', { name: 'Create' }).click();
@@ -393,7 +393,7 @@ test.describe('Command Palette', () => {
const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000);
// Create a new organization to ensure we have multiple
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create');
await page.getByLabel('Organization Name').fill(newOrgName);
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });

View File

@@ -907,7 +907,7 @@ test.describe('Employee Sidebar Navigation', () => {
// Visible links
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time', exact: true })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();

View File

@@ -1,5 +1,5 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
@@ -36,13 +36,52 @@ async function createTimeEntry(page, duration: string) {
test('test that organization name can be updated', async ({ page }) => {
await goToOrganizationSettings(page);
await page.getByLabel('Organization Name').fill('NEW ORG NAME');
await page.getByLabel('Organization Name').press('Enter');
await page.getByLabel('Organization Name').press('Meta+r');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page
.locator('form')
.filter({ hasText: 'Organization Name' })
.getByRole('button', { name: 'Save' })
.click(),
]);
await page.reload();
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
'NEW ORG NAME'
);
});
test('test that organization currency can be updated', async ({ page }) => {
await goToOrganizationSettings(page);
await page.getByLabel('Currency', { exact: true }).selectOption('USD');
await Promise.all([
page.waitForRequest(
(request) =>
request.url().includes('/api/v1/organizations/') &&
request.method() === 'PUT' &&
request.postDataJSON().currency === 'USD'
),
page.waitForResponse(
async (response) =>
response.url().includes('/api/v1/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.currency === 'USD'
),
page
.locator('form')
.filter({ hasText: 'Organization Name' })
.getByRole('button', { name: 'Save' })
.click(),
]);
await page.reload();
await expect(page.getByLabel('Currency', { exact: true })).toHaveValue('USD');
});
test('test that organization billable rate can be updated with all existing time entries', async ({
page,
}) => {
@@ -369,13 +408,153 @@ test('test that format settings persist after page reload', async ({ page }) =>
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
});
// =============================================
// Create, Delete & Switch
// =============================================
test.describe('Organization Create, Delete & Switch', () => {
async function createOrganization(page, name: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create');
await page.getByLabel('Organization Name').fill(name);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('button', { name: 'Create' }).click(),
]);
// The backend switches the current organization to the new one and the
// frontend reloads into its dashboard.
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
}
test('can create a new organization and switches to it automatically', async ({ page }) => {
const newOrgName = 'CreateOrg' + Math.floor(Math.random() * 100000);
await createOrganization(page, newOrgName);
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
newOrgName
);
});
test('does not create an organization when the name is empty', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/organizations/create');
// The form posts to the API, which rejects the empty name with a 422.
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'POST' &&
response.status() === 422
),
page.getByRole('button', { name: 'Create' }).click(),
]);
// Validation failed, so we stay on the create form and never reach a
// dashboard. Assert on the form rather than the URL.
await expect(page.getByText('Organization Details')).toBeVisible();
await expect(page.getByRole('alert')).toContainText('The name field is required.');
await expect(page.getByLabel('Organization Name')).toHaveAttribute('aria-invalid', 'true');
await expect(page.getByTestId('dashboard_view')).toHaveCount(0);
});
test('can delete an organization', async ({ page }) => {
// Create a throwaway organization so the primary one is never deleted.
const orgName = 'DeleteOrg' + Math.floor(Math.random() * 100000);
await createOrganization(page, orgName);
// Open the (now current) throwaway organization's settings.
await goToOrganizationSettings(page);
// Open the confirmation modal, then confirm inside the dialog.
await page.getByRole('button', { name: 'Delete Organization' }).click();
await page.getByRole('dialog').getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('dialog').getByRole('button', { name: 'Delete Organization' }).click(),
]);
// We are redirected to the dashboard of a different organization.
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
await expect(
page.locator('[data-testid="organization_switcher"]:visible')
).not.toContainText(orgName);
});
test('delete organization shows an error when the password is wrong', async ({ page }) => {
const orgName = 'DeleteOrgWrongPassword' + Math.floor(Math.random() * 100000);
await createOrganization(page, orgName);
await goToOrganizationSettings(page);
await page.getByRole('button', { name: 'Delete Organization' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/organizations') &&
response.request().method() === 'DELETE' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Organization' }).click(),
]);
await expect(dialog.getByRole('alert')).toBeVisible();
await expect(dialog).toBeVisible();
});
test('can switch the current organization via the organization switcher', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');
await expect(orgSwitcher).toBeVisible();
const previousOrgNameLines = (await orgSwitcher.innerText())
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const previousOrgName = previousOrgNameLines[previousOrgNameLines.length - 1];
// Ensure there are at least two organizations to switch between.
const orgName = 'SwitchOrg' + Math.floor(Math.random() * 100000);
await createOrganization(page, orgName);
await expect(orgSwitcher).toContainText(orgName);
// Open the switcher and pick a different organization.
await orgSwitcher.click();
await expect(page.getByText('Switch Organizations')).toBeVisible();
const otherOrgButton = page.getByRole('menuitem', { name: previousOrgName });
await expect(otherOrgButton).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/users/me/current-organization') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
otherOrgButton.click(),
]);
await expect(orgSwitcher).not.toContainText(orgName, { timeout: 10000 });
await expect(orgSwitcher).toContainText(previousOrgName, { timeout: 10000 });
});
});
// =============================================
// Admin Permission Tests
// =============================================
test.describe('Admin Organization Settings Access', () => {
test('admin can see and edit organization settings', async ({ ctx, admin }) => {
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/organizations/' + ctx.orgId);
// Organization Name section is visible
await expect(
@@ -396,6 +575,9 @@ test.describe('Admin Organization Settings Access', () => {
// Save buttons should be visible (admin can update)
await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible();
// The Organization Name input is editable (admin can update)
await expect(admin.page.getByLabel('Organization Name')).toBeEnabled();
// Delete organization should NOT be visible (owner only)
await expect(
admin.page.getByRole('heading', { name: 'Delete Organization' })
@@ -409,13 +591,17 @@ test.describe('Admin Organization Settings Access', () => {
test.describe('Employee Organization Settings Restrictions', () => {
test('employee can see org name but not editable settings', async ({ ctx, employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/organizations/' + ctx.orgId);
// Organization Name section is visible (but inputs are disabled)
await expect(
employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })
).toBeVisible({ timeout: 10000 });
// The name and currency inputs are rendered but disabled (employee cannot update)
await expect(employee.page.getByLabel('Organization Name')).toBeDisabled();
await expect(employee.page.getByLabel('Currency')).toBeDisabled();
// Editable settings sections should NOT be visible
await expect(
employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
@@ -429,5 +615,10 @@ test.describe('Employee Organization Settings Restrictions', () => {
// Save button should not be visible (employee cannot update)
await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible();
// Delete organization should NOT be visible (owner only)
await expect(
employee.page.getByRole('heading', { name: 'Delete Organization' })
).not.toBeVisible();
});
});

View File

@@ -342,8 +342,8 @@ test('delete account shows an error when the password is wrong', async ({ page }
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/confirm-password') &&
response.request().method() === 'POST' &&
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),

View File

@@ -6,6 +6,7 @@ import { formatCentsWithOrganizationDefaults } from './utils/money';
import {
createProjectViaApi,
createPublicProjectViaApi,
createProjectMemberViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
@@ -116,6 +117,43 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await expect(page.getByText(newProjectName)).toBeVisible();
});
test('test that the client can be changed in the edit project modal', async ({ page, ctx }) => {
const projectName = 'Edit Client Project ' + Math.floor(1 + Math.random() * 100000);
const clientName = 'Assigned Client ' + Math.floor(1 + Math.random() * 100000);
await createProjectViaApi(ctx, { name: projectName });
const client = await createClientViaApi(ctx, { name: clientName });
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Open the project's Edit modal.
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
await expect(page.getByRole('dialog')).toBeVisible();
// Open the client dropdown (currently "No Client"), confirm it focuses, and pick the client.
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
const clientSearch = page.getByPlaceholder('Search for a client...');
await expect(clientSearch).toBeFocused();
await clientSearch.fill(clientName);
await page.getByRole('option', { name: clientName }).click();
// The trigger updates to the chosen client.
await expect(page.getByRole('dialog').getByRole('button', { name: clientName })).toBeVisible();
// Saving persists the client assignment.
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.client_id === client.id
),
]);
});
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
@@ -217,6 +255,59 @@ test('test that creating a non-billable project works', async ({ page }) => {
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that creating a public project via the modal works', async ({ page }) => {
const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
// Visibility defaults to Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.is_public === true
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => {
const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first();
await projectRow.getByRole('button').click();
await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click();
// Loaded as Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.is_public === true
),
]);
});
test('test that switching from custom rate to default rate clears billable rate', async ({
page,
ctx,
@@ -640,7 +731,7 @@ test('test that creating a project with estimated time in human-readable format
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using human-readable format
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2h 30m');
await estimatedTimeInput.press('Tab');
@@ -668,7 +759,7 @@ test('test that creating a project with estimated time using decimal notation wo
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('1.5');
await estimatedTimeInput.press('Tab');
@@ -696,7 +787,7 @@ test('test that creating a project with estimated time using comma decimal notat
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2,5');
await estimatedTimeInput.press('Tab');
@@ -727,7 +818,7 @@ test('test that updating estimated time on existing project works', async ({ pag
await page.getByRole('menuitem').getByText('Edit').first().click();
// Fill in estimated time
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('4h 15m');
await estimatedTimeInput.press('Tab');
@@ -748,7 +839,7 @@ test('test that estimated time input displays formatted value after blur', async
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
// Enter time in various formats and check the displayed value
await estimatedTimeInput.fill('90');
@@ -925,6 +1016,39 @@ test.describe('Employee Projects Restrictions', () => {
employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
).not.toBeVisible();
});
test('employee does not see private projects they are not a member of', async ({
ctx,
employee,
}) => {
const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000);
const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: publicName });
// createProjectViaApi defaults to is_public: false (private); the employee is not a member
await createProjectViaApi(ctx, { name: privateName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The public project is visible — confirms the list has loaded
await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 });
// The private project the employee is not a member of must not appear
await expect(employee.page.getByText(privateName)).not.toBeVisible();
});
test('employee can see a private project they are a member of', async ({ ctx, employee }) => {
const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
// Add the employee as a project member so the private project becomes visible to them
await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The private project is visible because the employee is a member
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
});
});
test.describe('Employee Billable Rate Visibility', () => {
@@ -967,3 +1091,119 @@ test.describe('Employee Billable Rate Visibility', () => {
await expect(projectRow).toContainText('200');
});
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Projects Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that project table paginates when there are more than 15 projects', async ({
page,
ctx,
}) => {
// Create 17 projects with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 0014, page 2 shows 1516.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Default sort is name asc; first 15 projects (0014) should be on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Project 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows should be mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 projects (15, 16).
await expect(page.getByRole('row')).toHaveCount(2);
// Return to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Jump to last page then back to first page.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Page 2 button should be marked as selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that project pagination is not shown when there are 15 or fewer projects', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createProjectViaApi(ctx, {
name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending: header click toggles asc → desc.
const nameHeader = page
.locator('[data-testid="project_table"] .select-none', { hasText: 'Name' })
.first();
await nameHeader.click();
// After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Index 00 should now be on page 2 (last in desc order).
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});

View File

@@ -717,3 +717,108 @@ test('test that keyboard navigation works in multiselect dropdown', async ({ pag
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
).toBeVisible();
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Reporting Detailed Pagination', () => {
test('test that detailed reporting paginates when there are more than 15 time entries', async ({
page,
ctx,
}) => {
// The detailed report paginates server-side with a page limit of 15.
// Create 17 time entries on a single project so we get exactly 2 pages.
const seed = Math.floor(Math.random() * 100000);
const projectName = `ReportPagProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
const descriptions = Array.from(
{ length: 17 },
(_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}`
);
await Promise.all(
descriptions.map((description) =>
createTimeEntryViaApi(ctx, {
description,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(descriptions[0]!).first()).toBeVisible({
timeout: 10000,
});
// Pagination nav should be rendered.
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Collect which descriptions are currently visible on page 1.
const visiblePage1 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage1.add(description);
}
}
// The page limit is 15 → exactly 15 entries visible on page 1.
expect(visiblePage1.size).toBe(15);
// Go to page 2 and wait for the server fetch.
await Promise.all([
page.getByRole('button', { name: 'Next Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
const visiblePage2 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage2.add(description);
}
}
// Page 2 should hold the remaining 2 entries, disjoint from page 1.
expect(visiblePage2.size).toBe(2);
for (const description of visiblePage2) {
expect(visiblePage1.has(description)).toBe(false);
}
// Across both pages, all 17 entries should have been visible.
expect(visiblePage1.size + visiblePage2.size).toBe(17);
// Page 2 button is selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
// Previous page returns to page 1.
await Promise.all([
page.getByRole('button', { name: 'Previous Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true);
});
test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `FewEntriesProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await Promise.all(
Array.from({ length: 5 }, (_, i) =>
createTimeEntryViaApi(ctx, {
description: `FewEntries ${i} ${seed}`,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';
import { test } from '../playwright/fixtures';
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
import { createTimeEntryWithTagViaApi } from './utils/api';
// Each test registers a new user and creates test data via the API
test.describe.configure({ timeout: 30000 });
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
page,
ctx,
}) => {
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
// The entry with tagA is excluded; the entry with tagB remains
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
});
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
page,
ctx,
}) => {
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
// "Contains" tagA -> only the tagA entry is listed
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
// "Does Not Contain" tagA -> flips to the tagB entry
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
});

View File

@@ -96,6 +96,37 @@ test('test that project multiselect search filters the option list', async ({ pa
await page.keyboard.press('Escape');
});
test('test that the project filter virtualizes a long list (renders only a window)', async ({
page,
ctx,
}) => {
// Create many projects so the dropdown must virtualize rather than render all of them.
const projectNames = Array.from(
{ length: 80 },
(_, i) => `VirtProj ${String(i).padStart(2, '0')}`
);
await Promise.all(projectNames.map((name) => createProjectViaApi(ctx, { name })));
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await page.getByRole('button', { name: 'Projects' }).first().click();
// Only a small window of options is mounted, far fewer than the 80+ projects that exist.
await expect(page.getByRole('option').first()).toBeVisible();
const renderedCount = await page.getByRole('option').count();
expect(renderedCount).toBeGreaterThan(0);
expect(renderedCount).toBeLessThan(60);
// Virtualization must not drop options: searching narrows the list to the one deep match.
// Wait for the filtered count to settle to 1 before asserting — checking the option while
// the virtualizer is still re-rendering can transiently match a stale row (Firefox CI flake).
await page.getByPlaceholder('Search for a Project...').fill('VirtProj 79');
await expect(page.getByRole('option')).toHaveCount(1);
await expect(page.getByRole('option')).toContainText('VirtProj 79');
await page.keyboard.press('Escape');
});
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);

View File

@@ -152,6 +152,49 @@ test('test that editing a task name works', async ({ page, ctx }) => {
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
});
test('test that the project can be searched and changed in the create task modal', async ({
page,
ctx,
}) => {
const sourceProject = 'Source Project ' + Math.floor(1 + Math.random() * 100000);
const targetProject = 'Target Project ' + Math.floor(1 + Math.random() * 100000);
await createProjectViaApi(ctx, { name: sourceProject });
const target = await createProjectViaApi(ctx, { name: targetProject });
await goToProjectsOverview(page);
await page.getByText(sourceProject).first().click();
await page.getByRole('button', { name: 'Create Task' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The project dropdown is pre-filled with the source project; open it.
await page.getByRole('dialog').getByRole('button', { name: sourceProject }).click();
// Opening the dropdown focuses the search input; searching narrows it to the target project.
const projectSearch = page.getByPlaceholder('Search for a project...');
await expect(projectSearch).toBeFocused();
await projectSearch.fill('Target Project');
await page.getByRole('option', { name: targetProject }).click();
// Selecting closes the dropdown and updates the trigger to the chosen project.
await expect(
page.getByRole('dialog').getByRole('button', { name: targetProject })
).toBeVisible();
// The new selection is what gets used when the task is created.
const taskName = 'Switched Task ' + Math.floor(1 + Math.random() * 100000);
await page.getByPlaceholder('Task Name').fill(taskName);
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Create Task' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/tasks') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.project_id === target.id
),
]);
});
test('test that creating a project with an existing client works', async ({ page, ctx }) => {
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);

View File

@@ -0,0 +1,437 @@
/**
* E2E coverage for the timesheet overlap-prevention logic introduced
* in `useTimesheetCellMutations` (Phase 1+2+3 of the overlap fix).
*
* Each test:
* 1. Pre-creates entries via the API to set up a deterministic
* day-of-work scenario,
* 2. Triggers ONE cell edit through the UI,
* 3. Reads the resulting entries back via the API and asserts on
* the start/end placement.
*
* Pre-creating rows (rather than driving the "Add row" + project picker
* UI) keeps the tests focused on the placement logic and out of the
* project-dropdown's flake surface.
*/
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
import {
createProjectViaApi,
createTimeEntryAtHourViaApi,
getTimeEntriesViaApi,
} from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
async function waitForTimesheetLoad(page: Page) {
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
const HOUR = 3600;
function utcHourOf(iso: string): number {
return new Date(iso).getUTCHours();
}
function utcMinuteOf(iso: string): number {
return new Date(iso).getUTCMinutes();
}
function sortByStart<T extends { start: string }>(entries: T[]): T[] {
return [...entries].sort((a, b) => a.start.localeCompare(b.start));
}
/**
* Returns the locator for the row whose project name matches the given
* substring. Robust against ordering changes.
*/
function rowByProject(page: Page, projectName: string) {
return page.locator('[data-testid="timesheet_row"]').filter({ hasText: projectName });
}
/**
* Returns the locator for the input in the (row, dayIndex) cell, where
* the row is identified by project name.
*/
function cellInputByProject(page: Page, projectName: string, dayIndex: number) {
return rowByProject(page, projectName)
.locator('[data-testid="timesheet_cell"]')
.nth(dayIndex)
.locator('input');
}
/** Asserts that no entries in the list overlap each other. */
function expectNoOverlaps(entries: Array<{ start: string; end: string | null }>) {
const sorted = sortByStart(entries.filter((e) => e.end !== null));
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!;
const curr = sorted[i]!;
expect(
curr.start >= prev.end!,
`entries overlap: ${prev.start}${prev.end} vs ${curr.start}${curr.end}`
).toBe(true);
}
}
// ──────────────────────────────────────────────────
// Phase 1: createCell — overlap avoidance when cell is empty
// ──────────────────────────────────────────────────
test('extendCell on a row that has no entries on the day yet places after another row (Scenario #4)', async ({
page,
ctx,
}) => {
// Setup: project A has Monday 09:0010:00, project B has Tuesday
// 09:0010:00. The B row is therefore visible on the timesheet but
// has an EMPTY cell on Monday. Typing into B's Monday cell exercises
// the createCell path (cell empty → place a new entry).
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectA = await createProjectViaApi(ctx, { name: 'OverlapAlpha' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBravo' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into project B's Monday cell. The createCell path should
// place it AFTER project A's 09:0010:00 (i.e. at 10:00 or later),
// not at 09:00.
const input = cellInputByProject(page, 'OverlapBravo', 0);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const bMondayEntry = entries.find(
(e) =>
e.project_id === projectB.id &&
new Date(e.start).getTime() >= monday.getTime() &&
new Date(e.start).getTime() < tuesday.getTime()
)!;
expect(bMondayEntry).toBeDefined();
// 09:00 is blocked → must be at 10:00 or later.
expect(utcHourOf(bMondayEntry.start)).toBeGreaterThanOrEqual(10);
expectNoOverlaps(entries);
});
test('createCell refuses to cross midnight when day is full (Scenario #3)', async ({
page,
ctx,
}) => {
// Setup: fill Monday 01:0023:00 (22 hours, leaving 1h before and
// 1h after — neither big enough for a 3h ask). Project B is on
// Tuesday so the B row exists with an empty Monday cell. Typing 3h
// into B's Monday cell should be refused.
//
// We start at 01:00 (not 00:00) because the API's time-entry
// filter excludes entries whose `start` equals the query's `start`
// bound exactly. Using 01:00 avoids that boundary condition.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectFull = await createProjectViaApi(ctx, { name: 'OverlapFull' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapNoRoom' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 1,
durationSeconds: 22 * HOUR,
projectId: projectFull.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapNoRoom', 0);
const seenMutationRequests: string[] = [];
const onRequest = (request: Request) => {
if (request.url().includes('/time-entries') && request.method() !== 'GET') {
seenMutationRequests.push(request.method());
}
};
page.on('request', onRequest);
await input.click();
await input.fill('3');
await input.press('Enter');
await expect(page.getByText("This day can't fit any more work")).toBeVisible();
page.off('request', onRequest);
const entries = await getTimeEntriesViaApi(ctx);
// The new project should still only have its Tuesday entry.
const newEntries = entries.filter((e) => e.project_id === projectNew.id);
expect(seenMutationRequests).toEqual([]);
expect(newEntries).toHaveLength(1);
expect(utcHourOf(newEntries[0]!.start)).toBe(9);
// The Tuesday entry's date is unchanged (still Tuesday).
expect(new Date(newEntries[0]!.start).getUTCDay()).toBe(2);
});
// ──────────────────────────────────────────────────
// Phase 2: extendCell — collision detection + split
// ──────────────────────────────────────────────────
test('extendCell splits the extension when another row blocks the path (Scenario #5)', async ({
page,
ctx,
}) => {
// Setup:
// - project A on Monday 09:0010:00 (1h)
// - project B on Monday 10:3011:30 (1h, blocker)
// Bumping A's Monday cell from 1h to 3h (+2h) should:
// - extend A to 09:0010:30 (filling the 30min gap)
// - place a new A entry at 11:3013:00 (the remaining 90min)
const monday = getCurrentWeekMonday();
const projectA = await createProjectViaApi(ctx, { name: 'OverlapExtend' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBlocker' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
startMinute: 30,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapExtend', 0);
await input.click();
await input.fill('3');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const aEntries = entries.filter((e) => e.project_id === projectA.id);
const bEntries = entries.filter((e) => e.project_id === projectB.id);
// The blocker is unchanged.
expect(bEntries).toHaveLength(1);
expect(utcHourOf(bEntries[0]!.start)).toBe(10);
expect(utcMinuteOf(bEntries[0]!.start)).toBe(30);
// Project A should now have 2 entries.
expect(aEntries).toHaveLength(2);
const sortedA = sortByStart(aEntries);
// Extended entry: 09:00 → 10:30
expect(utcHourOf(sortedA[0]!.start)).toBe(9);
expect(utcHourOf(sortedA[0]!.end!)).toBe(10);
expect(utcMinuteOf(sortedA[0]!.end!)).toBe(30);
// Split remainder: 11:30 → 13:00
expect(utcHourOf(sortedA[1]!.start)).toBe(11);
expect(utcMinuteOf(sortedA[1]!.start)).toBe(30);
// No overlaps anywhere on the day.
expectNoOverlaps(entries);
});
test('extendCell prefers latest-end (not latest-start) when nested entries exist (Scenario #6)', async ({
page,
ctx,
}) => {
// Pre-existing nested overlap on the same project:
// - outer: 09:00 → 12:00 (3h)
// - inner: 10:00 → 11:00 (1h, contained inside outer)
// The cell total is 3h + 1h = 4h. Bumping to 5h (+1h) should grow
// the OUTER entry's end to 13:00, not the inner.
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'OverlapNested' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: 3 * HOUR,
projectId: project.id,
description: 'outer',
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
durationSeconds: HOUR,
projectId: project.id,
description: 'inner',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(1);
const input = cellInputByProject(page, 'OverlapNested', 0);
await input.click();
await input.fill('5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const outer = entries.find((e) => e.description === 'outer')!;
const inner = entries.find((e) => e.description === 'inner')!;
expect(utcHourOf(outer.start)).toBe(9);
expect(utcHourOf(outer.end!)).toBe(13); // extended from 12:00 → 13:00
expect(utcHourOf(inner.start)).toBe(10);
expect(utcHourOf(inner.end!)).toBe(11); // unchanged
});
// ──────────────────────────────────────────────────
// Phase 1+2 spillover from previous day
// ──────────────────────────────────────────────────
test('createCell handles intra-week spillover from previous day (Scenario #2)', async ({
page,
ctx,
}) => {
// Setup: an entry that starts on Monday 22:00 and ends Tuesday 03:00
// (5h, crosses midnight INTO Tuesday). This spillover starts inside
// the loaded week, so the timesheet query loads it.
//
// Then we try to place 1h on Tuesday for a different project. The
// expected behavior: the new entry must NOT overlap the spillover.
// Tuesday 09:00 is well clear of the [00:00, 03:00) spillover, so
// 09:00 is the correct placement.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const wednesday = new Date(monday);
wednesday.setUTCDate(monday.getUTCDate() + 2);
const projectSpill = await createProjectViaApi(ctx, { name: 'OverlapSpill' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapToday' });
// Monday 22:00 → Tuesday 03:00 (5h spillover into Tuesday).
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 22,
durationSeconds: 5 * HOUR,
projectId: projectSpill.id,
});
// Stub Wednesday entry on the new project so its row is visible
// even before we type anything in Tuesday's cell.
await createTimeEntryAtHourViaApi(ctx, {
date: wednesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into the new project's Tuesday cell (day index 1).
const input = cellInputByProject(page, 'OverlapToday', 1);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const newTuesdayEntry = entries.find(
(e) =>
e.project_id === projectNew.id &&
new Date(e.start).getTime() >= tuesday.getTime() &&
new Date(e.start).getTime() < wednesday.getTime()
)!;
expect(newTuesdayEntry).toBeDefined();
// 09:00 is well past the spillover end (03:00) → should land at 09:00.
expect(utcHourOf(newTuesdayEntry.start)).toBe(9);
expectNoOverlaps(entries);
});

641
e2e/timesheet.spec.ts Normal file
View File

@@ -0,0 +1,641 @@
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { createProjectViaApi, createTaskViaApi, createTimeEntryOnDateViaApi } from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
function getLastWeekMonday(): Date {
const monday = getCurrentWeekMonday();
monday.setUTCDate(monday.getUTCDate() - 7);
return monday;
}
function getDayOfWeek(weekStart: Date, dayOffset: number): Date {
const date = new Date(weekStart);
date.setUTCDate(date.getUTCDate() + dayOffset);
return date;
}
async function waitForTimesheetLoad(page: Page) {
await page.waitForURL(/\/timesheet(?:$|\?)/);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
function addRowButton(page: Page) {
return page.getByRole('button', { name: /Add row/i }).first();
}
async function chooseRowIdentity(page: Page, optionName: string) {
await addRowButton(page).click();
const dialog = page.getByRole('dialog', { name: /Add row/i });
const dialogVisible = await dialog
.waitFor({ state: 'visible', timeout: 1000 })
.then(() => true)
.catch(() => false);
if (dialogVisible) {
await dialog.getByRole('option', { name: optionName }).click();
return;
}
if (optionName === 'No Project') return;
const row = page.locator('[data-testid="timesheet_row"]').first();
await row.getByText('No Project').click();
await page.getByText(optionName).click();
}
// ──────────────────────────────────────────────────
// Navigation & Page Load
// ──────────────────────────────────────────────────
test('timesheet renders empty with add row + copy last week actions', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
await expect(addRowButton(page)).toBeVisible();
await expect(page.getByRole('button', { name: /Copy last week/i })).toBeVisible();
});
// ──────────────────────────────────────────────────
// Display Existing Time Entries
// ──────────────────────────────────────────────────
test('timesheet displays existing time entries grouped by project', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const tuesday = getDayOfWeek(monday, 1);
const wednesday = getDayOfWeek(monday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Project Alpha' });
const projectB = await createProjectViaApi(ctx, { name: 'Project Beta' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: wednesday,
duration: '1h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: tuesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// Check that the grand total is shown
await expect(page.getByTestId('timesheet_grand_total')).toBeVisible();
});
test('timesheet groups entries by project and task combination', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Task Project' });
const taskA = await createTaskViaApi(ctx, { name: 'Task A', project_id: project.id });
const taskB = await createTaskViaApi(ctx, { name: 'Task B', project_id: project.id });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
taskId: taskA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
taskId: taskB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
});
// ──────────────────────────────────────────────────
// Enter Duration in Cell
// ──────────────────────────────────────────────────
test('entering duration in empty cell creates a time entry', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Duration Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Duration Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Click the first day cell and enter duration
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayCell = cells.first();
const mondayInput = mondayCell.locator('input');
await mondayInput.click();
await mondayInput.fill('2');
// Submit and wait for create response
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(201);
// Verify the cell shows the duration
await expect(mondayInput).not.toHaveValue('');
});
// ──────────────────────────────────────────────────
// Edit Duration (Increase)
// ──────────────────────────────────────────────────
test('increasing duration in cell extends the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Increase Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Click and change to 3 hours
await mondayInput.click();
await mondayInput.fill('3');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Edit Duration (Decrease)
// ──────────────────────────────────────────────────
test('decreasing duration in cell shortens the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Decrease Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '3h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Clear Cell
// ──────────────────────────────────────────────────
test('clearing a cell deletes all time entries for that project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Clear Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('0');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
test('Escape during cell edit reverts the displayed value without an API call', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Escape Cancel Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Capture the formatted display value before editing.
const originalValue = await mondayInput.inputValue();
expect(originalValue).toMatch(/2/);
let mutationFired = false;
page.on('request', (req) => {
if (req.url().includes('/time-entries') && req.method() !== 'GET') {
mutationFired = true;
}
});
await mondayInput.click();
await mondayInput.fill('5');
await mondayInput.press('Escape');
// The Escape handler reverts the displayed value synchronously, so
// once this assertion passes we know the handler ran. Any mutation
// request would have been queued by then.
await expect(mondayInput).toHaveValue(originalValue);
expect(mutationFired).toBe(false);
});
// ──────────────────────────────────────────────────
// Week Navigation
// ──────────────────────────────────────────────────
test('navigating to previous week shows entries from that week', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Last Week Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no entries
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Go to previous week — the row-count assertion below auto-retries
// until the new week's data arrives.
await page.getByTestId('timesheet_prev_week').click();
// Should now see the entry
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
});
test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the
// header label flips.
await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week
await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
});
// ──────────────────────────────────────────────────
// Copy Last Week
// ──────────────────────────────────────────────────
test('copy last week adds project rows from previous week without hours', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const lastWednesday = getDayOfWeek(lastMonday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Copy Project A' });
const projectB = await createProjectViaApi(ctx, { name: 'Copy Project B' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: lastWednesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should now show 2 rows (one per project)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// All row totals should be 0
const rowTotals = page.locator('[data-testid="timesheet_row_total"]');
const count = await rowTotals.count();
for (let i = 0; i < count; i++) {
await expect(rowTotals.nth(i)).toContainText('-');
}
});
test('copy last week does not duplicate rows that already exist', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const thisMonday = getCurrentWeekMonday();
const thisTuesday = getDayOfWeek(thisMonday, 1);
const project = await createProjectViaApi(ctx, { name: 'No Dup Project' });
// Create entry for last week
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
// Create entry for current week
await createTimeEntryOnDateViaApi(ctx, {
date: thisTuesday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should have 1 row (from current week entry)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should still have only 1 row (not duplicated)
await expect(rows).toHaveCount(1);
});
test('copy last week with time entries creates rows and entries', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Copy Time Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows and time entries"
await page.getByRole('button', { name: /Copy last week/i }).click();
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
page.getByText('Copy rows and time entries').click(),
]);
// Should now show 1 row with time entries
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Row total should not be 0 (entries were copied)
const rowTotal = page.locator('[data-testid="timesheet_row_total"]').first();
await expect(rowTotal).not.toContainText('0 h');
});
// ──────────────────────────────────────────────────
// Row Removal
// ──────────────────────────────────────────────────
test('can remove an empty project row without confirmation', async ({ page, ctx }) => {
const project = await createProjectViaApi(ctx, { name: 'Empty Remove Project' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, project.name);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover the row to reveal the X button, then click it
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Row should be removed immediately (no dialog)
await expect(rows).toHaveCount(0);
});
test('removing a row with entries shows confirmation dialog and deletes entries', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Delete Row Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover and click X
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Confirmation dialog should appear
await expect(page.getByRole('alertdialog')).toBeVisible();
await expect(page.getByText('Remove timesheet row?')).toBeVisible();
// Click Delete
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
page
.getByRole('alertdialog')
.getByRole('button', { name: /Delete/i })
.click(),
]);
// Row should be gone
await expect(rows).toHaveCount(0);
});
// ──────────────────────────────────────────────────
// Multiple Entries Same Cell
// ──────────────────────────────────────────────────
test('cell correctly sums multiple entries for same project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Sum Test' });
// Create 2 entries for the same project on Monday
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
description: 'Entry 1',
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
description: 'Entry 2',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should be 1 row (both entries grouped)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// The Monday cell should show 3h total
const cells = rows.first().locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// The value should contain "3" (for 3h in some format)
await expect(mondayInput).toHaveValue(/3/);
});
// ──────────────────────────────────────────────────
// Duration Input Formats
// ──────────────────────────────────────────────────
test('cell accepts various duration input formats', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Format Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Format Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Test entering "1.5" (should be 1h 30min)
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1.5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
// 1.5 hours = 1h 30min
await expect(mondayInput).toHaveValue('1h 30min');
});

View File

@@ -9,7 +9,14 @@ import {
} from './utils/currentTimeEntry';
import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
import {
createProjectViaApi,
createTaskViaApi,
createClientViaApi,
archiveProjectViaApi,
markTaskDoneViaApi,
updateOrganizationCurrencyViaWeb,
} from './utils/api';
// Date picker button name patterns for different date formats
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
@@ -441,3 +448,236 @@ test('test that adding a project and tag before starting timer works', async ({
]);
await assertThatTimerIsStopped(page);
});
// ──────────────────────────────────────────────────
// Project / Task selector dropdown
// Regression coverage for the virtualized + lookup-map refactor of
// TimeTrackerProjectTaskDropdown. The dropdown only (re)filters on open and on search
// change, so we wait for the dashboard prefetch to settle before opening it.
// ──────────────────────────────────────────────────
test.describe('Project Task Dropdown', () => {
test.describe.configure({ timeout: 60_000 });
test('test that a project far down a long list can be found via search and selected', async ({
page,
ctx,
}) => {
// Seed enough projects that the target sits outside the initially rendered window.
const seed = Math.floor(Math.random() * 100000);
const prefix = `VirtProj ${seed} `;
await Promise.all(
Array.from({ length: 30 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
const target = prefix + '27';
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByTestId('client_dropdown_search').fill(target);
await page.getByRole('option').filter({ hasText: target }).click();
// The trigger now reflects the selected project.
await expect(page.getByRole('button', { name: target })).toBeVisible();
});
test('test that expanding a project and selecting a task works', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `ExpandProj ${seed}`;
const taskName = `ExpandTask ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const projectOption = page.getByRole('option').filter({ hasText: projectName });
await expect(projectOption).toBeVisible();
// Expand the project's tasks via the "N Tasks" button, then select the task.
await projectOption.getByText(/Tasks/).click();
await page.getByText(taskName, { exact: true }).click();
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
await expect(
page.getByRole('button', { name: `${projectName} ${taskName}` })
).toBeVisible();
});
test('test that keyboard navigation selects a project', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `KbProj ${seed}`;
await createProjectViaApi(ctx, { name: projectName });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
// On open the search is focused and "No Project" is highlighted.
await expect(search).toBeFocused();
// Arrow down from "No Project" to the project, then select it with Enter.
await search.press('ArrowDown');
await search.press('Enter');
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
});
test('test that search filters the dropdown by project and client name', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const clientName = `FilterClient ${seed}`;
const alphaProject = `AlphaProj ${seed}`;
const betaProject = `BetaProj ${seed}`;
const client = await createClientViaApi(ctx, { name: clientName });
await createProjectViaApi(ctx, { name: alphaProject, client_id: client.id });
await createProjectViaApi(ctx, { name: betaProject });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
const alphaOption = page.getByRole('option').filter({ hasText: alphaProject });
const betaOption = page.getByRole('option').filter({ hasText: betaProject });
// Both projects are visible before filtering.
await expect(alphaOption).toBeVisible();
await expect(betaOption).toBeVisible();
// Project-name search shows only the matching project.
await search.fill('AlphaProj');
await expect(alphaOption).toBeVisible();
await expect(betaOption).not.toBeVisible();
// Client-name search shows the project that belongs to that client.
await search.fill(clientName);
await expect(alphaOption).toBeVisible();
await expect(betaOption).not.toBeVisible();
});
test("test that searching by task name surfaces the task's project", async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectWithTask = `TaskSearchProj ${seed}`;
const taskName = `Findable Task ${seed}`;
const unrelatedProject = `Unrelated Proj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectWithTask });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
await createProjectViaApi(ctx, { name: unrelatedProject });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByTestId('client_dropdown_search').fill(taskName);
// The project owning the task is shown (with the task), the unrelated project is not.
await expect(page.getByRole('option').filter({ hasText: projectWithTask })).toBeVisible();
await expect(page.getByText(taskName, { exact: true })).toBeVisible();
await expect(
page.getByRole('option').filter({ hasText: unrelatedProject })
).not.toBeVisible();
});
test('test that archived projects are hidden from the dropdown', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const activeProject = `ActiveProj ${seed}`;
const archivedProject = `ArchivedProj ${seed}`;
await createProjectViaApi(ctx, { name: activeProject });
const toArchive = await createProjectViaApi(ctx, { name: archivedProject });
await archiveProjectViaApi(ctx, toArchive);
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
// Wait for the list to load, then confirm the archived project is filtered out.
await expect(page.getByRole('option').filter({ hasText: activeProject })).toBeVisible();
await expect(
page.getByRole('option').filter({ hasText: archivedProject })
).not.toBeVisible();
});
test('test that done tasks are hidden when expanding a project', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `DoneTaskProj ${seed}`;
const activeTask = `Active Task ${seed}`;
const doneTask = `Done Task ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: activeTask, project_id: project.id });
const taskToFinish = await createTaskViaApi(ctx, {
name: doneTask,
project_id: project.id,
});
await markTaskDoneViaApi(ctx, taskToFinish);
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const projectOption = page.getByRole('option').filter({ hasText: projectName });
await expect(projectOption).toBeVisible();
await projectOption.getByText(/Tasks/).click();
// Only the active task shows; the done task is filtered out.
await expect(page.getByText(activeTask, { exact: true })).toBeVisible();
await expect(page.getByText(doneTask, { exact: true })).not.toBeVisible();
});
test('test that keyboard navigation can expand a project and select a task', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `KbTaskProj ${seed}`;
const taskName = `KbTask ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
await expect(search).toBeFocused();
// No Project is highlighted on open: down to the project, right to expand its tasks,
// down to the task, Enter to select it.
await search.press('ArrowDown');
await search.press('ArrowRight');
await search.press('ArrowDown');
await search.press('Enter');
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
await expect(
page.getByRole('button', { name: `${projectName} ${taskName}` })
).toBeVisible();
});
test('test that pressing space selects the highlighted project', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `SpaceProj ${seed}`;
await createProjectViaApi(ctx, { name: projectName });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
await expect(search).toBeFocused();
// Arrow down from "No Project" to the project, then the space shortcut selects it.
await search.press('ArrowDown');
await search.press('Space');
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
});
});

192
e2e/two-factor.spec.ts Normal file
View File

@@ -0,0 +1,192 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
import { generateTotpCode, generateInvalidTotpCode } from './utils/totp';
import type { Page } from '@playwright/test';
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
/**
* ConfirmsPassword only opens the dialog when the password has not been
* confirmed recently, so fill it only when it actually shows up.
*/
async function confirmPasswordIfPrompted(page: Page) {
const dialog = page.getByRole('dialog');
const appeared = await dialog
.waitFor({ state: 'visible', timeout: 2500 })
.then(() => true)
.catch(() => false);
if (appeared) {
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await dialog.getByRole('button', { name: 'Confirm' }).click();
await expect(dialog).not.toBeVisible();
}
}
/**
* Enables 2FA from the profile page and returns the TOTP secret (setup key)
* and the recovery codes fetched right after enabling.
*/
async function enableTwoFactor(page: Page): Promise<{ secret: string; recoveryCodes: string[] }> {
await goToProfilePage(page);
await page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' })
.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const recoveryCodesResponse = page.waitForResponse(
(response) =>
response.url().includes('/user/two-factor-recovery-codes') &&
response.request().method() === 'GET'
);
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await dialog.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
const recoveryCodes: string[] = await (await recoveryCodesResponse).json();
const setupKeyText = await page.getByText('Setup Key:').textContent();
const secret = setupKeyText!.replace('Setup Key:', '').trim();
expect(secret.length).toBeGreaterThan(0);
return { secret, recoveryCodes };
}
/**
* Confirms a freshly enabled 2FA setup with a valid TOTP code.
*/
async function confirmTwoFactor(page: Page, secret: string) {
await page.getByLabel('Code').fill(generateTotpCode(secret));
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await confirmPasswordIfPrompted(page);
await expect(page.getByText('You have enabled two factor authentication.')).toBeVisible();
}
async function logout(page: Page) {
await page.getByTestId('current_user_button').click();
await page.getByText('Log Out', { exact: true }).click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
}
/**
* Reads the email of the current user from the profile form, waiting until
* the user query has populated it.
*/
async function getProfileEmail(page: Page): Promise<string> {
await goToProfilePage(page);
const emailInput = page.getByLabel('Email', { exact: true });
await expect(emailInput).toHaveValue(/@/);
return await emailInput.inputValue();
}
async function loginUntilTwoFactorChallenge(page: Page, email: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(TEST_USER_PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/two-factor-challenge');
}
test('test that 2FA can be confirmed with a TOTP code and shows recovery codes', async ({
page,
}) => {
const { secret, recoveryCodes } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await expect(page.getByText('Store these recovery codes')).toBeVisible();
expect(recoveryCodes.length).toBeGreaterThan(0);
for (const code of recoveryCodes) {
await expect(page.getByText(code)).toBeVisible();
}
// The confirmed state survives a reload
await page.reload();
await expect(page.getByText('You have enabled two factor authentication.')).toBeVisible();
});
test('test that 2FA confirmation fails with an invalid TOTP code', async ({ page }) => {
const { secret } = await enableTwoFactor(page);
await page.getByLabel('Code').fill(generateInvalidTotpCode(secret));
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await confirmPasswordIfPrompted(page);
await expect(page.getByRole('alert')).toContainText(
'The provided two factor authentication code was invalid.'
);
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
});
test('test that recovery codes can be regenerated', async ({ page }) => {
const { secret, recoveryCodes } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
const newCodesResponse = page.waitForResponse(
(response) =>
response.url().includes('/user/two-factor-recovery-codes') &&
response.request().method() === 'GET'
);
await page.getByRole('button', { name: 'Regenerate Recovery Codes' }).click();
await confirmPasswordIfPrompted(page);
const newCodes: string[] = await (await newCodesResponse).json();
expect(newCodes).not.toEqual(recoveryCodes);
await expect(page.getByText(newCodes[0])).toBeVisible();
await expect(page.getByText(recoveryCodes[0])).not.toBeVisible();
});
test('test that 2FA can be disabled', async ({ page }) => {
const { secret } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await page.getByRole('button', { name: 'Disable' }).click();
await confirmPasswordIfPrompted(page);
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
// The disabled state survives a reload
await page.reload();
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
});
test('test that login challenges for a TOTP code and rejects an invalid code', async ({ page }) => {
const email = await getProfileEmail(page);
const { secret } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await logout(page);
await loginUntilTwoFactorChallenge(page, email);
await page.getByLabel('Code').fill(generateInvalidTotpCode(secret));
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('alert')).toContainText(
'The provided two factor authentication code was invalid.'
);
// Fortify rejects replayed codes, and the current window's code was
// already consumed when confirming the setup — use the next window's
// code, which the +/- 1 step verification window also accepts.
await page.getByLabel('Code').fill(generateTotpCode(secret, Date.now() + 30_000));
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
});
test('test that login works with a recovery code', async ({ page }) => {
const email = await getProfileEmail(page);
const { secret, recoveryCodes } = await enableTwoFactor(page);
await confirmTwoFactor(page, secret);
await logout(page);
await loginUntilTwoFactorChallenge(page, email);
await page.getByRole('button', { name: 'Use a recovery code' }).click();
await page.getByLabel('Recovery Code').fill(recoveryCodes[0]);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
});

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