Compare commits

..

37 Commits

Author SHA1 Message Date
Gregor Vostrak
4ff8a72f0b add comprehensive 2 factor authentication e2e tests 2026-06-10 14:13:21 +02:00
Gregor Vostrak
4790693017 add back destroy other browser sessions endpoint (jetstream migration) 2026-06-10 13:28:35 +02:00
Gregor Vostrak
3caf7438b5 update e2e test setup to use user settings api endpoint 2026-06-09 16:24:52 +02:00
Gregor Vostrak
d929d31847 change redirects and references to new organization routes 2026-06-09 13:08:22 +02:00
Gregor Vostrak
d7bb36d50f add currency to organization update endpoint 2026-06-09 12:37:20 +02:00
Gregor Vostrak
b3785f0aa6 replace hardcoded inertia props with organization delete/update perms 2026-06-09 01:43:13 +02:00
Gregor Vostrak
8e47f07f09 remove unused inertia organization page props 2026-06-09 00:32:44 +02:00
Gregor Vostrak
da611086e8 fix inertia backend role data structure after jetstream migration 2026-06-09 00:19:36 +02:00
Gregor Vostrak
a220d0e592 call api for organization create/update/delete and switch 2026-06-09 00:12:55 +02:00
Constantin Graf
0e2c4431a0 Fixed current organization after normal registration 2026-06-08 23:06:07 +02:00
Constantin Graf
2f4c079f9f Added tests 2026-06-08 22:57:02 +02:00
Gregor Vostrak
f826474f88 add switch current organization endpoint 2026-06-08 18:57:23 +02:00
Constantin Graf
98bbe800f1 Removed Laravel Jetstream 2026-06-08 17:34:55 +02:00
Gregor Vostrak
7035d5fd6e remove jetstream inertia properties; remove unused ApiTokenManager; 2026-06-05 16:43:01 +02:00
Gregor Vostrak
f32ec59bb5 move banners on login and register cards into the cards 2026-05-29 17:40:16 +02:00
Gregor Vostrak
d2b6be137f add pending email cancel button 2026-05-29 17:40:16 +02:00
Constantin Graf
dc082b2b19 Replaces all Jetstream model trait functions and relations 2026-05-29 17:40:16 +02:00
Constantin Graf
82ad8ee316 Add reset pending email endpoint to user controller 2026-05-29 17:40:16 +02:00
Gregor Vostrak
117c3c4b6c move user delete to api endpoint 2026-05-29 17:40:16 +02:00
Gregor Vostrak
4c2586936d use api routes for profile information updates 2026-05-29 17:40:16 +02:00
Gregor Vostrak
ca843168f6 show null billable rate as empty not as 0 to avoid confusion 2026-05-29 17:40:16 +02:00
Gregor Vostrak
67dcf77635 fix e2e selectors to adapt to reka-ui change; 2026-05-29 17:40:16 +02:00
Gregor Vostrak
dcd21345b2 add pending email to UserResource and update openapi client 2026-05-29 17:40:16 +02:00
Gregor Vostrak
1f832a24a0 update ui package dependencies; update lucide imports 2026-05-29 17:40:16 +02:00
Gregor Vostrak
07cf3f7405 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-05-29 17:37:14 +02:00
Gregor Vostrak
a880ccb32c update npm dependencies 2026-05-29 17:37:13 +02:00
Gregor Vostrak
5a41c356d4 add profile page e2e tests 2026-05-29 17:27:16 +02:00
Gregor Vostrak
72bddfba8b update email address change info to use session based banners 2026-05-29 17:27:16 +02:00
Gregor Vostrak
34a1a89c30 add 1MB photo upload limit 2026-05-29 17:27:15 +02:00
Gregor Vostrak
77e4d768d4 add photo delete logic to user update endpoint 2026-05-29 17:27:15 +02:00
Constantin Graf
d42e3ffff0 Updated composer dependencies 2026-05-29 17:27:15 +02:00
Constantin Graf
4e26c8ad6d Add more tests 2026-05-29 17:27:15 +02:00
Constantin Graf
57794940f1 Add migration to lower case the user emails 2026-05-29 17:27:15 +02:00
Constantin Graf
09827d3d83 Migrate permission away from Jetstream; Moved update user to REST API 2026-05-29 17:27:15 +02:00
Gregor Vostrak
64c5da5223 rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-05-29 17:27:15 +02:00
Gregor Vostrak
983e6c3815 add banners for invitation accept 2026-05-29 17:27:15 +02:00
Constantin Graf
f34b60874e Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-05-29 17:27:15 +02:00
53 changed files with 153 additions and 828 deletions

View File

@@ -35,7 +35,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -46,9 +46,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -96,7 +96,7 @@ jobs:
node-version: '20.x' node-version: '20.x'
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
repository: solidtime-io/extension-invoicing repository: solidtime-io/extension-invoicing
path: extensions/Invoicing path: extensions/Invoicing
@@ -124,27 +124,27 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -163,7 +163,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -177,25 +177,25 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}

View File

@@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v5
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -33,9 +33,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: version id: version
@@ -73,7 +73,7 @@ jobs:
node-version: '20.x' node-version: '20.x'
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v6 uses: actions/checkout@v5
with: with:
repository: solidtime-io/extension-billing repository: solidtime-io/extension-billing
path: extensions/Billing path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci run: cd extensions/Billing && npm ci
- name: "Checkout services extension" - name: "Checkout services extension"
uses: actions/checkout@v6 uses: actions/checkout@v5
with: with:
repository: solidtime-io/extension-services repository: solidtime-io/extension-services
path: extensions/Services path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
uses: actions/checkout@v6 uses: actions/checkout@v5
with: with:
repository: solidtime-io/extension-invoicing repository: solidtime-io/extension-invoicing
path: extensions/Invoicing path: extensions/Invoicing
@@ -160,7 +160,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin
@@ -168,7 +168,7 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: rg.fr-par.scw.cloud/solidtime/solidtime images: rg.fr-par.scw.cloud/solidtime/solidtime
tags: | tags: |
@@ -179,13 +179,13 @@ jobs:
type=sha,format=long type=sha,format=long
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push" - name: "Build and push"
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
build-args: | build-args: |

View File

@@ -36,7 +36,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v5
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -47,9 +47,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -109,34 +109,34 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -155,7 +155,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -169,31 +169,31 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}

View File

@@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -52,7 +52,7 @@ jobs:
run: php artisan scramble:export --path=build/api-docs.json run: php artisan scramble:export --path=build/api-docs.json
- name: "Upload API docs to GitHub" - name: "Upload API docs to GitHub"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: api-docs.json name: api-docs.json
path: build/api-docs.json path: build/api-docs.json

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.0.0 uses: codecov/codecov-action@v5.5.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

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

View File

@@ -35,7 +35,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v6
@@ -86,7 +86,7 @@ jobs:
MAILPIT_BASE_URL: 'http://localhost:8025' MAILPIT_BASE_URL: 'http://localhost:8025'
- name: "Upload blob report" - name: "Upload blob report"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: blob-report-${{ matrix.shardIndex }} name: blob-report-${{ matrix.shardIndex }}
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
@@ -110,7 +110,7 @@ jobs:
run: npm ci run: npm ci
- name: "Download blob reports" - name: "Download blob reports"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
path: all-blob-reports path: all-blob-reports
pattern: blob-report-* pattern: blob-report-*
@@ -120,7 +120,7 @@ jobs:
run: npx playwright merge-reports --reporter html ./all-blob-reports run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: "Upload merged HTML report" - name: "Upload merged HTML report"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\AfterCreateOrganization; use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest; use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource; use App\Http\Resources\V1\Organization\OrganizationResource;
@@ -125,7 +124,7 @@ class OrganizationController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
{ {
$this->checkPermission($organization, 'organizations:delete'); $this->checkPermission($organization, 'organizations:delete');

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException; 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\UserUpdateCurrentOrganizationRequest;
use App\Http\Requests\V1\User\UserUpdateRequest; use App\Http\Requests\V1\User\UserUpdateRequest;
use App\Http\Resources\V1\User\UserResource; use App\Http\Resources\V1\User\UserResource;
@@ -194,7 +193,7 @@ class UserController extends Controller
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted. * @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. * @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/ */
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse public function destroy(User $user, DeletionService $deletionService): JsonResponse
{ {
if ($user->getKey() !== $this->user()->getKey()) { if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException; throw new AuthorizationException;

View File

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

View File

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

@@ -12,7 +12,6 @@ use App\Enums\TimeFormat;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Rules\CurrencyRule; use App\Rules\CurrencyRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** /**
@@ -23,7 +22,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>> * @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|\Illuminate\Contracts\Validation\ValidationRule>>
*/ */
public function rules(): array public function rules(): array
{ {

View File

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

@@ -116,12 +116,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $record['Description']; $timeEntry->description = $record['Description'];
if (isset($record['Billable'])) { if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
if (! in_array($record['Billable'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value');
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
} }
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
@@ -221,6 +219,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Group', 'Group',
'Email', 'Email',
'Tags', 'Tags',
'Billable',
'Start Date', 'Start Date',
'Start Time', 'Start Time',
'End Date', 'End Date',

View File

@@ -81,8 +81,6 @@ return [
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false), 'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Timezone | Application Timezone

View File

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

View File

@@ -1,5 +1,5 @@
import { expect, test } from '../playwright/fixtures'; import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToOrganizationSettings(page) { async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
@@ -471,7 +471,6 @@ test.describe('Organization Create, Delete & Switch', () => {
// Open the confirmation modal, then confirm inside the dialog. // Open the confirmation modal, then confirm inside the dialog.
await page.getByRole('button', { name: 'Delete Organization' }).click(); await page.getByRole('button', { name: 'Delete Organization' }).click();
await page.getByRole('dialog').getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([ await Promise.all([
page.waitForResponse( page.waitForResponse(
(response) => (response) =>
@@ -489,28 +488,6 @@ test.describe('Organization Create, Delete & Switch', () => {
).not.toContainText(orgName); ).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 }) => { test('can switch the current organization via the organization switcher', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible'); const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');

View File

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

View File

@@ -62,35 +62,4 @@ describe('TimesheetCell', () => {
expect(wrapper.emitted('update')).toBeUndefined(); expect(wrapper.emitted('update')).toBeUndefined();
expect((input.element as HTMLInputElement).value).toBe(previousValue); expect((input.element as HTMLInputElement).value).toBe(previousValue);
}); });
it('shows a pending 0 (delete in flight) over the cell total', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
pendingSeconds: 0,
},
});
// `??` (not `||`): a pending 0 must win over the 2h cell total.
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('');
});
it('disables editing while the cell is saving', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
saveStatus: 'saving',
},
});
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
});
}); });

View File

@@ -1,8 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { CheckIcon } from '@heroicons/vue/16/solid';
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue'; import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -10,40 +7,18 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/packages/ui/src/tooltip'; } from '@/packages/ui/src/tooltip';
import type { TimesheetCell } from '@/utils/useTimesheetGrid'; import type { TimesheetCell } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const props = defineProps<{ defineProps<{
cell?: TimesheetCell; cell?: TimesheetCell;
dayIndex: number; dayIndex: number;
date: string; date: string;
isToday: boolean; isToday: boolean;
hasRunningEntry: boolean; hasRunningEntry: boolean;
saveStatus?: CellSaveStatus;
pendingSeconds?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
update: [newSeconds: number]; update: [newSeconds: number];
}>(); }>();
// Show the optimistic value while saving; `??` (not `||`) so a pending 0 (delete) wins.
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
const isSaving = computed(() => props.saveStatus === 'saving');
// Swap the border color (don't layer) to avoid same-specificity fights.
const inputClass = computed(() => {
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
return [
'w-[80px] mx-auto text-center font-medium',
'bg-transparent text-text-primary placeholder:text-text-quaternary',
'rounded-lg border shadow-none',
border,
'hover:bg-card-background',
'focus-visible:bg-tertiary focus-visible:border-transparent',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
'disabled:cursor-wait disabled:opacity-70',
].join(' ');
});
</script> </script>
<template> <template>
@@ -71,26 +46,18 @@ const inputClass = computed(() => {
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent> <TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<template v-else> <DurationSecondsInput
<span class="relative inline-flex items-center"> v-else
<DurationSecondsInput :model-value="cell?.totalSeconds ?? 0"
:model-value="displaySeconds" default-unit="hours"
default-unit="hours" placeholder="-"
placeholder="-" size="sm"
size="sm" input-class="w-[80px] mx-auto text-center font-medium
:disabled="isSaving" bg-transparent text-text-primary placeholder:text-text-quaternary
:input-class="inputClass" rounded-lg border border-input-border shadow-none
@commit="(seconds) => emit('update', seconds ?? 0)" /> hover:bg-card-background
<span focus-visible:bg-tertiary focus-visible:border-transparent
v-if="saveStatus === 'saving' || saveStatus === 'saved'" focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
class="pointer-events-none absolute left-full top-1/2 ml-1.5 flex -translate-y-1/2 items-center" @commit="(seconds) => emit('update', seconds ?? 0)" />
:aria-label="saveStatus === 'saving' ? 'Saving' : 'Saved'">
<LoadingSpinner
v-if="saveStatus === 'saving'"
class="h-3 w-3 m-0 text-text-tertiary" />
<CheckIcon v-else class="h-3 w-3 text-text-tertiary" />
</span>
</span>
</template>
</div> </div>
</template> </template>

View File

@@ -15,7 +15,6 @@ import type {
Task, Task,
} from '@/packages/api/src'; } from '@/packages/api/src';
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid'; import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
const dayjs = getDayJsInstance(); const dayjs = getDayJsInstance();
@@ -37,8 +36,6 @@ defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>; createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string; formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -63,7 +60,7 @@ const emit = defineEmits<{
class="grid min-w-full w-max border-y border-default-background-separator" class="grid min-w-full w-max border-y border-default-background-separator"
style=" style="
grid-template-columns: grid-template-columns:
minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto) minmax(420px, 1fr) repeat(7, minmax(96px, 120px)) minmax(100px, auto)
40px; 40px;
"> ">
<!-- Header row --> <!-- Header row -->
@@ -103,8 +100,6 @@ const emit = defineEmits<{
:create-client="createClient" :create-client="createClient"
:create-tag="createTag" :create-tag="createTag"
:format-duration="formatDuration" :format-duration="formatDuration"
:cell-statuses="cellStatuses"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="$emit('remove-row', $event)" @remove-row="$emit('remove-row', $event)"
@cell-update=" @cell-update="
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds) (dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)

View File

@@ -15,10 +15,6 @@ import type {
Organization, Organization,
} from '@/packages/api/src'; } from '@/packages/api/src';
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid'; import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import {
makeCellStatusKey,
type CellSaveStatus,
} from '@/utils/timesheet/useTimesheetCellMutations';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
@@ -38,8 +34,6 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>; createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string; formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -115,8 +109,6 @@ function hasRunningEntry(dayIndex: number): boolean {
:date="day" :date="day"
:is-today="day === todayDate" :is-today="day === todayDate"
:has-running-entry="hasRunningEntry(dayIndex)" :has-running-entry="hasRunningEntry(dayIndex)"
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" /> @update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
<!-- Row total --> <!-- Row total -->

View File

@@ -298,7 +298,7 @@ const page = usePage<{
<div class="justify-self-end"> <div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification> <UpdateSidebarNotification></UpdateSidebarNotification>
<ul <ul
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center"> class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon> <UserSettingsIcon></UserSettingsIcon>
<NavigationSidebarItem <NavigationSidebarItem

View File

@@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import axios from 'axios';
import ActionSection from '@/Components/ActionSection.vue'; import ActionSection from '@/Components/ActionSection.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue'; import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { Field, FieldError } from '@/packages/ui/src/field'; import { Field, FieldError } from '@/packages/ui/src/field';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useDeleteUserMutation } from '@/utils/useUserQuery'; import { useDeleteUserMutation, useUserQuery } from '@/utils/useUserQuery';
import { getCurrentUserId } from '@/utils/useUser';
const { user } = useUserQuery();
const deleteUserMutation = useDeleteUserMutation(); const deleteUserMutation = useDeleteUserMutation();
const confirmingUserDeletion = ref(false); const confirmingUserDeletion = ref(false);
@@ -23,26 +24,26 @@ function confirmUserDeletion() {
} }
async function deleteUser() { async function deleteUser() {
if (processing.value) return; if (!user.value || processing.value) return;
processing.value = true; processing.value = true;
passwordError.value = ''; passwordError.value = '';
try { try {
await deleteUserMutation.mutateAsync({ await axios.post(route('password.confirm'), { password: password.value });
userId: getCurrentUserId(),
body: { password: password.value },
});
window.location.href = '/';
} catch (error) { } catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}
}
processing.value = false; processing.value = false;
if (axios.isAxiosError(error) && error.response?.status === 422) {
passwordError.value = error.response.data?.errors?.password?.[0] ?? 'Invalid password.';
} else {
passwordError.value = 'Could not confirm password. Please try again.';
}
passwordInput.value?.focus(); passwordInput.value?.focus();
return;
}
try {
await deleteUserMutation.mutateAsync(user.value.id);
window.location.href = '/';
} catch {
processing.value = false;
} }
} }

View File

@@ -2,11 +2,9 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue'; import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue'; import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { Field, FieldError } from '@/packages/ui/src/field';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useOrganizationStore } from '@/utils/useOrganization'; import { useOrganizationStore } from '@/utils/useOrganization';
const props = defineProps<{ const props = defineProps<{
@@ -14,46 +12,26 @@ const props = defineProps<{
}>(); }>();
const confirmingTeamDeletion = ref(false); const confirmingTeamDeletion = ref(false);
const passwordInput = ref<HTMLInputElement | null>(null);
const password = ref('');
const passwordError = ref('');
const processing = ref(false); const processing = ref(false);
const organizationStore = useOrganizationStore(); const organizationStore = useOrganizationStore();
const confirmTeamDeletion = () => { const confirmTeamDeletion = () => {
confirmingTeamDeletion.value = true; confirmingTeamDeletion.value = true;
setTimeout(() => passwordInput.value?.focus(), 250);
}; };
const deleteTeam = async () => { const deleteTeam = async () => {
if (processing.value) return;
processing.value = true; processing.value = true;
passwordError.value = '';
try { try {
await organizationStore.deleteOrganization(props.team.id, { password: password.value }); await organizationStore.deleteOrganization(props.team.id);
// The backend reassigns the user's current organization after deletion, // The backend reassigns the user's current organization after deletion,
// so flush the prefetch cache and reload into the dashboard. // so flush the prefetch cache and reload into the dashboard.
router.flushAll(); router.flushAll();
router.visit(route('dashboard')); router.visit(route('dashboard'));
} catch (error) { } catch {
if (error && typeof error === 'object' && 'response' in error) { // Request errors are surfaced as notifications by the store.
const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } }
| undefined;
if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
}
}
processing.value = false; processing.value = false;
passwordInput.value?.focus();
} }
}; };
const closeModal = () => {
confirmingTeamDeletion.value = false;
password.value = '';
passwordError.value = '';
};
</script> </script>
<template> <template>
@@ -74,30 +52,20 @@ const closeModal = () => {
</div> </div>
<!-- Delete Organization Confirmation Modal --> <!-- Delete Organization Confirmation Modal -->
<DialogModal :show="confirmingTeamDeletion" @close="closeModal"> <ConfirmationModal
:show="confirmingTeamDeletion"
@close="confirmingTeamDeletion = false">
<template #title> Delete Organization </template> <template #title> Delete Organization </template>
<template #content> <template #content>
Are you sure you want to delete this organization? Once a organization is Are you sure you want to delete this organization? Once a organization is
deleted, all of its resources and data will be permanently deleted. Please enter deleted, all of its resources and data will be permanently deleted.
your password to confirm you would like to permanently delete this organization.
<Field class="mt-4">
<TextInput
ref="passwordInput"
v-model="password"
type="password"
class="block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="deleteTeam" />
<FieldError v-if="passwordError">{{ passwordError }}</FieldError>
</Field>
</template> </template>
<template #footer> <template #footer>
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton> <SecondaryButton @click="confirmingTeamDeletion = false">
Cancel
</SecondaryButton>
<DangerButton <DangerButton
class="ms-3" class="ms-3"
@@ -107,7 +75,7 @@ const closeModal = () => {
Delete Organization Delete Organization
</DangerButton> </DangerButton>
</template> </template>
</DialogModal> </ConfirmationModal>
</template> </template>
</ActionSection> </ActionSection>
</template> </template>

View File

@@ -43,17 +43,17 @@ onMounted(async () => {
<template v-else> <template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" /> <UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder v-if="permissions.canUpdateTeam" /> <SectionBorder />
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" /> <OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" /> <OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" /> <OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam"> <template v-if="permissions.canDeleteTeam">
<SectionBorder />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" /> <DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template> </template>
</template> </template>

View File

@@ -90,12 +90,7 @@ const weekRangeDisplay = computed(() => {
}); });
// ── Cell / row mutation handlers ────────────────────────────────── // ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations( const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot);
weekDays,
timeEntries,
rows,
removeSlot
);
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations( const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
mutations, mutations,
@@ -172,8 +167,6 @@ async function createTag(name: string): Promise<Tag | undefined> {
:create-client="createClient" :create-client="createClient"
:create-tag="createTag" :create-tag="createTag"
:format-duration="formatDuration" :format-duration="formatDuration"
:cell-statuses="cellStatus"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="handleRemoveRow" @remove-row="handleRemoveRow"
@cell-update="handleCellUpdate" @cell-update="handleCellUpdate"
@project-task-change=" @project-task-change="

View File

@@ -114,8 +114,6 @@ export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>; export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;
export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0]; export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>; export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;
@@ -126,8 +124,6 @@ export type UpdateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoice'>
export type User = ZodiosResponseByAlias<SolidTimeApi, 'getMe'>['data']; export type User = ZodiosResponseByAlias<SolidTimeApi, 'getMe'>['data'];
export type UpdateUserBody = ZodiosBodyByAlias<SolidTimeApi, 'updateUser'>; export type UpdateUserBody = ZodiosBodyByAlias<SolidTimeApi, 'updateUser'>;
export type DeleteUserBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteUser'>;
export type DeleteOrganizationBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteOrganization'>;
const api = createApiClient('/api', { validate: 'none' }); const api = createApiClient('/api', { validate: 'none' });

View File

@@ -37,7 +37,6 @@ const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passth
const ClientUpdateRequest = z const ClientUpdateRequest = z
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() }) .object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
.passthrough(); .passthrough();
const DestroyWithPasswordRequest = z.object({ password: z.string() }).passthrough();
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough(); const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
const InvitationResource = z const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() }) .object({ id: z.string(), email: z.string(), role: z.string() })
@@ -918,11 +917,6 @@ const endpoints = makeApi([
alias: 'deleteOrganization', alias: 'deleteOrganization',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{
name: 'body',
type: 'Body',
schema: DestroyWithPasswordRequest,
},
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
@@ -1969,54 +1963,6 @@ const endpoints = makeApi([
}, },
], ],
}, },
{
method: 'post',
path: '/v1/organizations/:organization/invoices/:invoice/copy',
alias: 'copyInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ reference: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoice',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization/invoices/:invoice', path: '/v1/organizations/:organization/invoices/:invoice',
@@ -4648,11 +4594,6 @@ the organization.`,
description: `This endpoint is independent of the organization.`, description: `This endpoint is independent of the organization.`,
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{
name: 'body',
type: 'Body',
schema: DestroyWithPasswordRequest,
},
{ {
name: 'user', name: 'user',
type: 'Path', type: 'Path',

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ref } from 'vue'; import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { useTimesheetCellMutations, makeCellStatusKey } from './useTimesheetCellMutations'; import { useTimesheetCellMutations } from './useTimesheetCellMutations';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid'; import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid';
import type { TimeEntry } from '@/packages/api/src'; import type { TimeEntry } from '@/packages/api/src';
@@ -549,119 +549,3 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
}); });
}); });
}); });
describe('useTimesheetCellMutations save status', () => {
// Timer handles keep old fade-outs from clearing newer status, and
// the same-cell saving guard prevents concurrent writes from stale rows.
it('does not let a stale fade-out timer clear a newer edit on the same cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
// Re-edit the same cell partway through the first "saved" window.
vi.advanceTimersByTime(1000);
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
// Advance past the FIRST timer's deadline: it must not wipe the newer state.
vi.advanceTimersByTime(2000);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
});
it('ignores another commit while the same cell is saving', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
let release!: () => void;
const gateA = new Promise<void>((res) => {
release = () => res();
});
apiMocks.createTimeEntry.mockImplementationOnce(async () => {
await gateA;
return { data: { id: 'a' } } as never;
});
const save = cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saving');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
// The second commit would be planned from the same stale row, so it is ignored.
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
release();
await save;
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
});
it('marks error and drops the optimistic value when the save fails', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom'));
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
'Failed to update timesheet',
expect.any(String)
);
});
it('marks error and drops the optimistic value when the day is full', async () => {
// Block all but the last 2h, then ask for 3h → NoFreeWindowError.
const blocker = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', { id: 'blocker' });
const { cellMutations } = setup([blocker]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, 3 * HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
"This day can't fit any more work",
expect.any(String)
);
});
it('creates no status when the committed value is unchanged', async () => {
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z');
const { cellMutations } = setup([cellEntry]);
const row = buildRow('p-1', [cellEntry]);
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBeUndefined();
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
});
it('tracks save status independently for each cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const mondayKey = makeCellStatusKey(row.key, 0);
const tuesdayKey = makeCellStatusKey(row.key, 1);
await cellMutations.handleCellUpdate(row, 0, HOUR);
await cellMutations.handleCellUpdate(row, 1, 2 * HOUR);
expect(cellMutations.cellStatus.value[mondayKey]).toBe('saved');
expect(cellMutations.cellStatus.value[tuesdayKey]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[mondayKey]).toBe(HOUR);
expect(cellMutations.cellPendingSeconds.value[tuesdayKey]).toBe(2 * HOUR);
});
});

View File

@@ -1,4 +1,4 @@
import { ref, type Ref } from 'vue'; import type { Ref } from 'vue';
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src'; import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
@@ -19,17 +19,6 @@ import {
type FreeWindow, type FreeWindow,
} from './cellMath'; } from './cellMath';
export type CellSaveStatus = 'saving' | 'saved' | 'error';
/** Map key for a cell's save state (row + day). */
export function makeCellStatusKey(rowKey: TimesheetRowKey, dayIndex: number): string {
return `${rowKey}:${dayIndex}`;
}
/** How long the saved/error state stays visible before fading. */
const SAVED_VISIBLE_MS = 2800;
const ERROR_VISIBLE_MS = 2500;
/** /**
* Cell-level edit dispatcher. Picks one of four strategies based on * Cell-level edit dispatcher. Picks one of four strategies based on
* the diff between current and requested totals: * the diff between current and requested totals:
@@ -59,58 +48,15 @@ export function useTimesheetCellMutations(
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const notifications = useNotificationsStore(); const notifications = useNotificationsStore();
// Save status + the optimistic value shown while saving, so a saved cell
// doesn't flicker back to its old total before the refetch lands.
const cellStatus = ref<Record<string, CellSaveStatus>>({});
const cellPendingSeconds = ref<Record<string, number>>({});
const statusClearTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function clearStatusTimer(key: string): void {
clearTimeout(statusClearTimers[key]);
delete statusClearTimers[key];
}
function beginSaving(key: string, seconds: number): void {
clearStatusTimer(key);
cellPendingSeconds.value[key] = seconds;
cellStatus.value[key] = 'saving';
}
function markSaved(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'saved';
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete cellPendingSeconds.value[key];
delete statusClearTimers[key];
}, SAVED_VISIBLE_MS);
}
function markError(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'error';
// Drop the optimistic value so the cell shows server truth after refetch.
delete cellPendingSeconds.value[key];
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete statusClearTimers[key];
}, ERROR_VISIBLE_MS);
}
async function handleCellUpdate( async function handleCellUpdate(
row: TimesheetRow, row: TimesheetRow,
dayIndex: number, dayIndex: number,
newTotalSeconds: number newTotalSeconds: number
): Promise<void> { ): Promise<void> {
const statusKey = makeCellStatusKey(row.key, dayIndex);
if (cellStatus.value[statusKey] === 'saving') return;
const cell = row.cells.get(dayIndex); const cell = row.cells.get(dayIndex);
const existingSeconds = cell?.totalSeconds ?? 0; const existingSeconds = cell?.totalSeconds ?? 0;
if (newTotalSeconds === existingSeconds) return; if (newTotalSeconds === existingSeconds) return;
beginSaving(statusKey, newTotalSeconds);
// Capture row state before the mutation: a row that was empty // Capture row state before the mutation: a row that was empty
// and shares identity with another slot collapses after the // and shares identity with another slot collapses after the
// first entry lands, so the entry naturally identity-routes to // first entry lands, so the entry naturally identity-routes to
@@ -128,9 +74,7 @@ export function useTimesheetCellMutations(
'Another row with the same project, task, billable status and tags already exists.' 'Another row with the same project, task, billable status and tags already exists.'
); );
} }
markSaved(statusKey);
} catch (err) { } catch (err) {
markError(statusKey);
if (err instanceof NoFreeWindowError) { if (err instanceof NoFreeWindowError) {
const friendlyDuration = formatHumanReadableDuration( const friendlyDuration = formatHumanReadableDuration(
err.requiredSeconds, err.requiredSeconds,
@@ -149,6 +93,7 @@ export function useTimesheetCellMutations(
'Failed to update timesheet', 'Failed to update timesheet',
'Please try again later.' 'Please try again later.'
); );
throw err;
} finally { } finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
} }
@@ -371,5 +316,5 @@ export function useTimesheetCellMutations(
return best; return best;
} }
return { handleCellUpdate, cellStatus, cellPendingSeconds }; return { handleCellUpdate };
} }

View File

@@ -2,11 +2,9 @@ import { router } from '@inertiajs/vue3';
import { initializeStores } from '@/utils/init'; import { initializeStores } from '@/utils/init';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import axios from 'axios';
import type { import type {
Organization, Organization,
OrganizationResponse, OrganizationResponse,
DeleteOrganizationBody,
UpdateOrganizationBody, UpdateOrganizationBody,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
@@ -40,7 +38,7 @@ export async function switchOrganization(organizationId: string) {
export const useOrganizationStore = defineStore('organization', () => { export const useOrganizationStore = defineStore('organization', () => {
const organizationResponse = ref<OrganizationResponse | null>(null); const organizationResponse = ref<OrganizationResponse | null>(null);
const { addNotification, handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchOrganization() { async function fetchOrganization() {
const organization = getCurrentOrganizationId(); const organization = getCurrentOrganizationId();
@@ -80,26 +78,17 @@ export const useOrganizationStore = defineStore('organization', () => {
return response?.data ?? null; return response?.data ?? null;
} }
async function deleteOrganization(organizationId: string, body: DeleteOrganizationBody) { async function deleteOrganization(organizationId: string) {
try { await handleApiRequestNotifications(
await api.deleteOrganization(body, { () =>
params: { api.deleteOrganization(undefined, {
organization: organizationId, params: {
}, organization: organizationId,
}); },
addNotification('success', 'Organization deleted successfully'); }),
} catch (error) { 'Organization deleted successfully',
if (!axios.isAxiosError(error) || error.response?.status !== 422) { 'Failed to delete organization'
addNotification( );
'error',
'Failed to delete organization',
axios.isAxiosError(error)
? (error.response?.data?.message ?? 'Please try again later.')
: 'Please try again later.'
);
}
throw error;
}
} }
const organization = computed<Organization | null>(() => { const organization = computed<Organization | null>(() => {

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { computed } from 'vue'; import { computed } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api, type DeleteUserBody, type UpdateUserBody, type User } from '@/packages/api/src'; import { api, type UpdateUserBody, type User } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
const ME_QUERY_KEY = ['me'] as const; const ME_QUERY_KEY = ['me'] as const;
@@ -61,9 +61,9 @@ export function useDeleteUserMutation() {
const { addNotification } = useNotificationsStore(); const { addNotification } = useNotificationsStore();
return useMutation({ return useMutation({
mutationFn: async ({ userId, body }: { userId: string; body: DeleteUserBody }) => { mutationFn: async (userId: string) => {
try { try {
await api.deleteUser(body, { params: { user: userId } }); await api.deleteUser(undefined, { params: { user: userId } });
} catch (error) { } catch (error) {
if (!axios.isAxiosError(error) || error.response?.status !== 422) { if (!axios.isAxiosError(error) || error.response?.status !== 422) {
addNotification( addNotification(

View File

@@ -1,3 +0,0 @@
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Task User Group Email Tags Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -33,7 +33,6 @@ Route::get('/shared-report', function () {
Route::middleware([ Route::middleware([
'auth:web', 'auth:web',
'auth.session',
'verified', 'verified',
])->group(function (): void { ])->group(function (): void {
Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard'); Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard');

View File

@@ -441,9 +441,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]), [ $response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
'password' => 'password',
]);
// Assert // Assert
$response->assertForbidden(); $response->assertForbidden();
@@ -458,54 +456,12 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid']), [ $response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid']));
'password' => 'password',
]);
// Assert // Assert
$response->assertNotFound(); $response->assertNotFound();
} }
public function test_delete_endpoint_fails_without_password(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['password']);
$this->assertDatabaseHas(Organization::class, [
'id' => $data->organization->getKey(),
]);
}
public function test_delete_endpoint_fails_with_wrong_password(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]), [
'password' => 'wrong-password',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['password']);
$this->assertDatabaseHas(Organization::class, [
'id' => $data->organization->getKey(),
]);
}
public function test_delete_endpoint_can_delete_organization(): void public function test_delete_endpoint_can_delete_organization(): void
{ {
// Arrange // Arrange
@@ -516,9 +472,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]), [ $response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
'password' => 'password',
]);
// Assert // Assert
$response->assertNoContent(); $response->assertNoContent();

View File

@@ -649,9 +649,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($otherData->user); Passport::actingAs($otherData->user);
// Act // Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()), [ $response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
'password' => 'password',
]);
// Assert // Assert
$response->assertForbidden(); $response->assertForbidden();
@@ -676,46 +674,12 @@ class UserEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->deleteJson(route('api.v1.users.destroy', 'not-valid'), [ $response = $this->deleteJson(route('api.v1.users.destroy', 'not-valid'));
'password' => 'password',
]);
// Assert // Assert
$response->assertNotFound(); $response->assertNotFound();
} }
public function test_delete_fails_without_password(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['password']);
$this->assertDatabaseHas(User::class, ['id' => $data->user->getKey()]);
}
public function test_delete_fails_with_wrong_password(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()), [
'password' => 'wrong-password',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['password']);
$this->assertDatabaseHas(User::class, ['id' => $data->user->getKey()]);
}
public function test_delete_removes_user(): void public function test_delete_removes_user(): void
{ {
// Arrange // Arrange
@@ -723,9 +687,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()), [ $response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
'password' => 'password',
]);
// Assert // Assert
$response->assertNoContent(); $response->assertNoContent();

View File

@@ -4,17 +4,12 @@ declare(strict_types=1);
namespace Tests\Unit\Filament\Resources; namespace Tests\Unit\Filament\Resources;
use App\Enums\Role;
use App\Events\OrganizationInvitationAdding;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Mail\OrganizationInvitationMail;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\User; use App\Models\User;
use App\Service\DeletionService; use App\Service\DeletionService;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire; use Livewire\Livewire;
use Mockery\MockInterface; use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesClass;
@@ -117,34 +112,4 @@ class OrganizationResourceTest extends FilamentTestCase
$response->assertSuccessful(); $response->assertSuccessful();
$response->assertCanSeeTableRecords($organizationInvitations); $response->assertCanSeeTableRecords($organizationInvitations);
} }
public function test_can_create_related_invitation(): void
{
// Arrange
Event::fake([
OrganizationInvitationAdding::class,
]);
Mail::fake();
$organization = Organization::factory()->create();
// Act
$response = Livewire::test(OrganizationResource\RelationManagers\InvitationsRelationManager::class, [
'ownerRecord' => $organization,
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
])->callTableAction('create', data: [
'email' => 'new-user@example.com',
'role' => Role::Employee->value,
]);
// Assert
$response->assertSuccessful();
$response->assertHasNoTableActionErrors();
$this->assertDatabaseHas(OrganizationInvitation::class, [
'organization_id' => $organization->getKey(),
'email' => 'new-user@example.com',
'role' => Role::Employee->value,
]);
Event::assertDispatched(OrganizationInvitationAdding::class);
Mail::assertQueued(OrganizationInvitationMail::class);
}
} }

View File

@@ -73,14 +73,13 @@ class EnsureEmailIsVerifiedMiddlewareTest extends MiddlewareTestAbstract
$response->assertOk(); $response->assertOk();
} }
public function test_users_with_unverified_email_can_access_route_in_local_environment_if_local_email_verification_is_disabled(): void public function test_users_with_unverified_email_can_access_route_in_local_environment(): void
{ {
// Arrange // Arrange
$user = User::factory()->unverified()->create(); $user = User::factory()->unverified()->create();
$route = $this->createTestRoute(); $route = $this->createTestRoute();
$this->actingAs($user); $this->actingAs($user);
$this->app->detectEnvironment(fn () => 'local'); $this->app->detectEnvironment(fn () => 'local');
config(['app.local_email_verification' => false]);
// Act // Act
$response = $this->get($route); $response = $this->get($route);
@@ -88,36 +87,4 @@ class EnsureEmailIsVerifiedMiddlewareTest extends MiddlewareTestAbstract
// Assert // Assert
$response->assertOk(); $response->assertOk();
} }
public function tests_users_with_unverified_email_are_redirected_in_non_local_environment_even_if_local_email_verification_is_disabled(): void
{
// Arrange
$user = User::factory()->unverified()->create();
$route = $this->createTestRoute();
$this->actingAs($user);
$this->assertSame('testing', config('app.env'));
config(['app.local_email_verification' => false]);
// Act
$response = $this->get($route);
// Assert
$response->assertRedirect(route('verification.notice'));
}
public function test_users_with_unverified_email_are_redirected_in_local_environment_if_local_email_verification_is_enabled(): void
{
// Arrange
$user = User::factory()->unverified()->create();
$route = $this->createTestRoute();
$this->actingAs($user);
$this->app->detectEnvironment(fn () => 'local');
config(['app.local_email_verification' => true]);
// Act
$response = $this->get($route);
// Assert
$response->assertRedirect(route('verification.notice'));
}
} }

View File

@@ -41,30 +41,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->clientsCreated); $this->assertSame(1, $report->clientsCreated);
} }
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
$this->checkTimeEntries($testScenario, false, true);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_with_special_characters_description_succeeds(): void public function test_import_of_test_with_special_characters_description_succeeds(): void
{ {
// Arrange // Arrange

View File

@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
*/ */
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object
{ {
$users = User::all(); $users = User::all();
$this->assertCount(2, $users); $this->assertCount(2, $users);
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('#ef5350', $project1->color); $this->assertSame('#ef5350', $project1->color);
$this->assertSame(null, $project1->billable_rate); $this->assertSame(null, $project1->billable_rate);
// Project for Big Company // Project for Big Company
$this->assertSame(! $billableDefault, $project2->is_billable); $this->assertSame(true, $project2->is_billable);
$this->assertSame(false, $project2->is_public); $this->assertSame(false, $project2->is_public);
$this->assertSame('#ec407a', $project2->color); $this->assertSame('#ec407a', $project2->color);
$this->assertSame(10001, $project2->billable_rate); $this->assertSame(10001, $project2->billable_rate);
// Project (Archived) // Project (Archived)
$this->assertSame(! $billableDefault, $project3->is_billable); $this->assertSame(true, $project3->is_billable);
$this->assertSame(true, $project3->is_public); $this->assertSame(true, $project3->is_public);
$this->assertSame('#6a407f', $project3->color); $this->assertSame('#6a407f', $project3->color);
$this->assertSame(null, $project3->billable_rate); $this->assertSame(null, $project3->billable_rate);
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario * @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
*/ */
protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void
{ {
$timeEntries = TimeEntry::all(); $timeEntries = TimeEntry::all();
if ($secondRun) { if ($secondRun) {
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('Working hard', $timeEntry2->description); $this->assertSame('Working hard', $timeEntry2->description);
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString()); $this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
$this->assertSame(! $billableDefault, $timeEntry2->billable); $this->assertTrue($timeEntry2->billable);
$this->assertTrue($timeEntry2->is_imported); $this->assertTrue($timeEntry2->is_imported);
$this->assertSame([], $timeEntry2->tags); $this->assertSame([], $timeEntry2->tags);
} }

View File

@@ -14,7 +14,6 @@ async function getConfig() {
const additionalPlugins = await collectModulePlugins('extensions'); const additionalPlugins = await collectModulePlugins('extensions');
return defineConfig({ return defineConfig({
base: './',
build: { build: {
sourcemap: true, // Source map generation must be turned on sourcemap: true, // Source map generation must be turned on
}, },