Compare commits

..

24 Commits

Author SHA1 Message Date
Gregor Vostrak
fb4bb6ef33 add invoice copy to openapi client 2026-05-29 15:34:54 +02:00
Gregor Vostrak
dc5e8e7de2 move banners on login and register cards into the cards 2026-05-29 15:33:35 +02:00
Gregor Vostrak
5821f7c688 add pending email cancel button 2026-05-29 15:31:33 +02:00
Constantin Graf
22e865a69e Replaces all Jetstream model trait functions and relations 2026-05-29 12:40:06 +02:00
Constantin Graf
5391a7abc8 Add reset pending email endpoint to user controller 2026-05-28 20:45:27 +02:00
Gregor Vostrak
c8623b7e70 move user delete to api endpoint 2026-05-27 19:06:39 +02:00
Gregor Vostrak
3b1702221b use api routes for profile information updates 2026-05-27 18:20:10 +02:00
Gregor Vostrak
4432174439 show null billable rate as empty not as 0 to avoid confusion 2026-05-27 13:12:59 +02:00
Gregor Vostrak
ccb16118a9 fix e2e selectors to adapt to reka-ui change; 2026-05-27 13:12:10 +02:00
Gregor Vostrak
dad686d107 add pending email to UserResource and update openapi client 2026-05-26 18:02:15 +02:00
Gregor Vostrak
414b5d3294 update ui package dependencies; update lucide imports 2026-05-26 17:30:30 +02:00
Gregor Vostrak
e9217df338 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-05-26 17:21:40 +02:00
Gregor Vostrak
96a0c21b5e update npm dependencies 2026-05-26 17:19:42 +02:00
Gregor Vostrak
8e7c8a1e1b add profile page e2e tests 2026-05-26 17:11:28 +02:00
Gregor Vostrak
6299e242a9 update email address change info to use session based banners 2026-05-26 14:03:30 +02:00
Gregor Vostrak
c573d31ef9 add 1MB photo upload limit 2026-05-26 13:59:44 +02:00
Gregor Vostrak
00ffabe108 add photo delete logic to user update endpoint 2026-05-26 13:23:31 +02:00
Constantin Graf
5b756be058 Updated composer dependencies 2026-05-22 16:18:02 +02:00
Constantin Graf
dc70eb7130 Add more tests 2026-05-22 16:06:51 +02:00
Constantin Graf
c2a8eac65f Add migration to lower case the user emails 2026-05-21 23:22:27 +02:00
Constantin Graf
28ecfc63a3 Migrate permission away from Jetstream; Moved update user to REST API 2026-05-21 23:22:09 +02:00
Gregor Vostrak
433a6f3770 rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-05-20 22:10:10 +02:00
Gregor Vostrak
0ba20fd24c add banners for invitation accept 2026-05-20 21:42:02 +02:00
Constantin Graf
3267acb161 Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-05-20 16:25:17 +02:00
257 changed files with 2709 additions and 13408 deletions

View File

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

23
.github/VOUCHED.td vendored
View File

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

View File

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

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

@@ -8,7 +8,6 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-public.yml' - '.github/workflows/build-public.yml'
- '.dockerignore'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
@@ -37,7 +36,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v7 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
@@ -48,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
@@ -93,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: '20.x' node-version: '20.x'
@@ -110,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
@@ -156,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/*
@@ -170,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@v7 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@v7 uses: actions/checkout@v5
- name: "Setup PHP (for Ziggy)" - name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: '20.x' node-version: '20.x'

View File

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

View File

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

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v7 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
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

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

View File

@@ -1,27 +0,0 @@
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@v7
- name: "Use Node.js"
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Run vitest"
run: npm run test:unit

View File

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

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v7 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@v7 uses: actions/checkout@v5
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install" - name: "Run composer install"
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: '20.x' node-version: '20.x'
@@ -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@v7 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,10 +35,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v7 uses: actions/checkout@v5
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: '20.x' node-version: '20.x'
@@ -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@v7 uses: actions/checkout@v4
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v7 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

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

View File

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

View File

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

3
.gitignore vendored
View File

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

1
.npmrc
View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,16 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Exceptions\MovedToApiException; use App\Enums\Weekday;
use App\Mail\VerifyUpdatedEmailMail;
use App\Models\User; 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 Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation; use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -20,6 +27,61 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/ */
public function update(User $user, array $input): void public function update(User $user, array $input): void
{ {
throw new MovedToApiException; 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();
}
} }
} }

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,30 @@
<?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,12 +4,8 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum Role: string enum Role: string
{ {
use LaravelEnumHelper;
case Owner = 'owner'; case Owner = 'owner';
case Admin = 'admin'; case Admin = 'admin';
case Manager = 'manager'; case Manager = 'manager';

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TagMatchType: string
{
use LaravelEnumHelper;
case Contains = 'contains';
case NotContains = 'not_contains';
}

View File

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

@@ -1,38 +0,0 @@
<?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(); $ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class) return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user()); ->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
}), }),
]) ])
->actions([ ->actions([

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,6 @@ class MemberController extends Controller
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->with(['user']) ->with(['user'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members); return MemberCollection::make($members);
@@ -193,7 +192,7 @@ class MemberController extends Controller
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
} }
$invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user()); $invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204); return response()->json(null, 204);
} }

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;
@@ -51,9 +50,6 @@ class OrganizationController extends Controller
if ($request->getName() !== null) { if ($request->getName() !== null) {
$organization->name = $request->getName(); $organization->name = $request->getName();
} }
if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency();
}
if ($request->getEmployeesCanSeeBillableRates() !== null) { if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
} }
@@ -125,7 +121,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

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

View File

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

View File

@@ -59,7 +59,7 @@ class ReportController extends Controller
$filter->addBillable($properties->billable); $filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray()); $filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray()); $filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType); $filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTaskIdsFilter($properties->taskIds?->toArray()); $filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray()); $filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get(); $timeEntriesQuery = $filter->get();

View File

@@ -47,7 +47,6 @@ class ReportController extends Controller
$reports = Report::query() $reports = Report::query()
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
@@ -97,7 +96,6 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null)); $properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null)); $properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null)); $properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagMatchType($request->getPropertyTagMatchType());
$properties->setTaskIds($request->input('properties.task_ids', null)); $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; $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone; $timezone = $user->timezone;

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ class TimeEntryController extends Controller
$query = TimeEntry::query() $query = TimeEntry::query()
->where('organization_id', $organization->getKey()) ->where('organization_id', $organization->getKey())
->where('member_id', $member->getKey()) ->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void { ->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey()); $q->where('id', '!=', $exclude->getKey());
}) })
@@ -107,8 +107,8 @@ class TimeEntryController extends Controller
/** /**
* Get time entries in organization * Get time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `member_id`. * If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
* *
* @return TimeEntryCollection<TimeEntryResource> * @return TimeEntryCollection<TimeEntryResource>
* *
@@ -118,17 +118,16 @@ class TimeEntryController extends Controller
*/ */
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count(); $totalCount = $timeEntriesQuery->count();
@@ -159,7 +158,7 @@ class TimeEntryController extends Controller
if ($timeEntries->count() === 0) { if ($timeEntries->count() === 0) {
Log::warning('User has has more than '.$limit.' time entries on one date', [ Log::warning('User has has more than '.$limit.' time entries on one date', [
'date' => $lastDate->toDateString(), 'date' => $lastDate->toDateString(),
'member_id' => $request->input('member_id'), 'user_id' => $request->input('user_id'),
'auth_user_id' => Auth::id(), 'auth_user_id' => Auth::id(),
'limit' => $limit, 'limit' => $limit,
]); ]);
@@ -195,8 +194,7 @@ class TimeEntryController extends Controller
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->select($select) ->select($select)
->orderBy('time_entries.start', 'desc') ->orderBy('start', 'desc');
->orderBy('time_entries.id');
$filter = new TimeEntryFilter($timeEntriesQuery); $filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start')); $filter->addStartFilter($request->input('start'));
@@ -205,7 +203,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member); $filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids')); $filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids')); $filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType()); $filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
@@ -222,10 +220,9 @@ class TimeEntryController extends Controller
*/ */
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
@@ -242,7 +239,7 @@ class TimeEntryController extends Controller
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
'task', 'task',
'client', 'client',
@@ -265,7 +262,7 @@ class TimeEntryController extends Controller
if ($viewFile === false) { if ($viewFile === false) {
throw new \LogicException('View file not found'); throw new \LogicException('View file not found');
} }
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery, $timeEntriesAggregateQuery,
null, null,
@@ -372,10 +369,9 @@ class TimeEntryController extends Controller
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
@@ -386,7 +382,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -422,10 +418,9 @@ class TimeEntryController extends Controller
*/ */
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
@@ -441,7 +436,7 @@ class TimeEntryController extends Controller
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -564,7 +559,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member); $filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids')); $filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids')); $filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType()); $filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
@@ -584,7 +579,7 @@ class TimeEntryController extends Controller
{ {
/** @var Member $member */ /** @var Member $member */
$member = Member::query()->findOrFail($request->input('member_id')); $member = Member::query()->findOrFail($request->input('member_id'));
if ($member->getKey() === $this->member($organization)->getKey()) { if ($member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:create:own'); $this->checkPermission($organization, 'time-entries:create:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:create:all'); $this->checkPermission($organization, 'time-entries:create:all');
@@ -631,10 +626,9 @@ class TimeEntryController extends Controller
*/ */
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $newMember */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry); $this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else { } else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry); $this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
@@ -666,10 +660,6 @@ class TimeEntryController extends Controller
} }
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user()->associate($newMember->user);
}
$timeEntry->description = $request->input('description', $timeEntry->description) ?? ''; $timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
@@ -699,7 +689,6 @@ class TimeEntryController extends Controller
*/ */
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
{ {
$member = $this->member($organization);
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']); $this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all'); $canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
@@ -724,9 +713,6 @@ class TimeEntryController extends Controller
throw new AuthorizationException; throw new AuthorizationException;
} }
/** @var Member|null $newMember */
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
$project = null; $project = null;
$client = null; $client = null;
$overwriteClient = false; $overwriteClient = false;
@@ -753,7 +739,7 @@ class TimeEntryController extends Controller
continue; continue;
} }
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) { if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id); $error->push($id);
continue; continue;
@@ -763,10 +749,6 @@ class TimeEntryController extends Controller
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;
$timeEntry->fill($changes); $timeEntry->fill($changes);
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user_id = $newMember->user_id;
}
// If project is changed, but task is not, we remove the old task from the time entry // If project is changed, but task is not, we remove the old task from the time entry
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) { if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate(); $timeEntry->task()->disassociate();
@@ -807,8 +789,7 @@ class TimeEntryController extends Controller
*/ */
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{ {
$member = $this->member($organization); if ($timeEntry->member->user_id === Auth::id()) {
if ($timeEntry->member_id === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry); $this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
} else { } else {
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
@@ -865,7 +846,7 @@ class TimeEntryController extends Controller
continue; continue;
} }
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) { if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id); $error->push($id);
continue; continue;

View File

@@ -1,33 +0,0 @@
<?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,15 +6,11 @@ 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\UserUpdateRequest; use App\Http\Requests\V1\User\UserUpdateRequest;
use App\Http\Resources\V1\User\UserResource; use App\Http\Resources\V1\User\UserResource;
use App\Mail\VerifyUpdatedEmailMail; use App\Mail\VerifyUpdatedEmailMail;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\DeletionService; use App\Service\DeletionService;
use App\Service\UserService;
use App\Support\Base64File; use App\Support\Base64File;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -40,35 +36,6 @@ class UserController extends Controller
return new UserResource($user); 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 * Update the current user
* *
@@ -83,7 +50,7 @@ class UserController extends Controller
} }
if ($request->hasPhotoKey()) { if ($request->hasPhotoKey()) {
$photoDisk = (string) config('filesystems.public'); $photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$previousPhotoPath = $user->profile_photo_path; $previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto(); $newPhoto = $request->getPhoto();
@@ -194,7 +161,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

@@ -4,21 +4,4 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Models\Organization; abstract class Controller extends \App\Http\Controllers\Controller {}
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

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

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

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

@@ -9,10 +9,12 @@ use App\Models\User;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
use Illuminate\Support\MessageBag; use Illuminate\Support\MessageBag;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class ShareInertiaData class ShareInertiaData
@@ -25,8 +27,27 @@ class ShareInertiaData
/** @var PermissionStore $permissions */ /** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class); $permissions = app(PermissionStore::class);
Inertia::share([ 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' => [ 'auth' => [
'permissions' => $request->user() !== null && $request->user()->currentOrganization !== null ? $permissions->getPermissions($request->user()->currentOrganization) : [], 'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],
'user' => function () use ($request): array { 'user' => function () use ($request): array {
/** @var User|null $user */ /** @var User|null $user */
$user = $request->user(); $user = $request->user();
@@ -35,8 +56,6 @@ class ShareInertiaData
return []; return [];
} }
$currentOrganization = $user->currentOrganization;
return array_merge([ return array_merge([
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
@@ -49,12 +68,12 @@ class ShareInertiaData
'profile_photo_url' => $user->profile_photo_url, 'profile_photo_url' => $user->profile_photo_url,
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication()) 'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
&& ! is_null($user->two_factor_secret), && ! is_null($user->two_factor_secret),
'current_team' => $currentOrganization !== null ? [ 'current_team' => $user->currentTeam !== null ? [
'id' => $currentOrganization->id, 'id' => $user->currentTeam->id,
'user_id' => $currentOrganization->user_id, 'user_id' => $user->currentTeam->user_id,
'name' => $currentOrganization->name, 'name' => $user->currentTeam->name,
'personal_team' => $currentOrganization->personal_team, 'personal_team' => $user->currentTeam->personal_team,
'currency' => $currentOrganization->currency, 'currency' => $user->currentTeam->currency,
] : null, ] : null,
], array_filter([ ], array_filter([
'all_teams' => $user->organizations->map(function (Organization $organization): array { 'all_teams' => $user->organizations->map(function (Organization $organization): array {

View File

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

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

@@ -11,8 +11,6 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat; 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 Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** /**
@@ -23,7 +21,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>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -32,10 +30,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string', 'string',
'max:255', 'max:255',
], ],
'currency' => [
'string',
new CurrencyRule,
],
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',
@@ -74,11 +68,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
return $this->has('name') ? (string) $this->input('name') : null; 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 public function getNumberFormat(): ?NumberFormat
{ {
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null; return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report; namespace App\Http\Requests\V1\Report;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
@@ -125,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
} }
}, },
], ],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [ 'properties.task_ids' => [
'nullable', 'nullable',
'array', 'array',
@@ -255,15 +249,6 @@ class ReportStoreRequest extends BaseFormRequest
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); 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 public function getPropertyRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) { if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
@@ -140,10 +139,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -251,15 +246,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
return ExportFormat::from($this->validated('format')); 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 public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
@@ -126,10 +125,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -213,15 +208,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; 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 public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -111,10 +110,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -220,15 +215,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return ExportFormat::from($this->validated('format')); 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 public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
@@ -104,10 +103,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -195,15 +190,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0; 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 public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory; use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -22,6 +21,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; 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; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -49,7 +53,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* *
* @method static OrganizationFactory factory() * @method static OrganizationFactory factory()
*/ */
class Organization extends Model implements AuditableContract class Organization extends JetstreamTeam implements AuditableContract
{ {
use CustomAuditable; use CustomAuditable;
@@ -87,6 +91,17 @@ class Organization extends Model implements AuditableContract
'personal_team', '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. * The model's default values for attributes.
* *
@@ -148,13 +163,12 @@ class Organization extends Model implements AuditableContract
} }
/** /**
* Find a model by its primary key or throw an exception. * 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.
* *
* @param array<int, string> $columns * @param array<string> $columns
*
* @throws ModelNotFoundException<Model>
*/ */
public static function findOrFail(string $id, array $columns = ['*']): Model public function findOrFail(string $id, array $columns = ['*']): Team
{ {
if (! Str::isUuid($id)) { if (! Str::isUuid($id)) {
throw (new ModelNotFoundException)->setModel( throw (new ModelNotFoundException)->setModel(

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
<?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,11 +4,14 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode; use App\Models\Passport\AuthCode;
use App\Models\Passport\Client; use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken; use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Jetstream\Jetstream;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider class AuthServiceProvider extends ServiceProvider
@@ -19,6 +22,7 @@ class AuthServiceProvider extends ServiceProvider
* @var array<class-string, class-string> * @var array<class-string, class-string>
*/ */
protected $policies = [ protected $policies = [
Organization::class => OrganizationPolicy::class,
]; ];
/** /**
@@ -52,5 +56,11 @@ class AuthServiceProvider extends ServiceProvider
// Passport::tokensExpireIn(now()->addDays(15)); // Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30)); // Passport::refreshTokensExpireIn(now()->addDays(30));
Passport::personalAccessTokensExpireIn(now()->addMonths(12)); 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,13 +15,12 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse; use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Responses\LoginResponse;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
{ {
@@ -51,40 +50,6 @@ 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 { Fortify::authenticateUsing(function (Request $request): ?User {
/** @var User|null $user */ /** @var User|null $user */
$user = User::query() $user = User::query()
@@ -109,7 +74,7 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id')); return Limit::perMinute(5)->by($request->session()->get('login.id'));
}); });
$this->app->instance(LoginResponseContract::class, new CustomLoginResponse); $this->app->instance(LoginResponse::class, new CustomLoginResponse);
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse); $this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
} }
} }

View File

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

View File

@@ -1,179 +0,0 @@
<?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,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use Exception; use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Csv\Exception as CsvException; use League\Csv\Exception as CsvException;
use League\Csv\Reader; use League\Csv\Reader;
@@ -25,12 +24,10 @@ class ClockifyProjectsImporter extends DefaultImporter
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$billableRateKey = $this->getBillableRateKey($header); $billableRateKey = $this->getBillableRateKey($header);
$tasksKey = $this->getTasksKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$clientId = null; $clientId = null;
// Newer Clockify exports no longer contain a "Client" column. if ($record['Client'] !== '') {
if (($record['Client'] ?? '') !== '') {
$clientId = $this->clientImportHelper->getKey([ $clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'], 'name' => $record['Client'],
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -46,13 +43,12 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]); ]);
} }
if ($tasksKey !== null && $record[$tasksKey] !== '') { if ($record['Task'] !== '') {
$tasks = explode(', ', $record[$tasksKey]); $tasks = explode(', ', $record['Task']);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
@@ -81,36 +77,17 @@ class ClockifyProjectsImporter extends DefaultImporter
{ {
$requiredFields = [ $requiredFields = [
'Project', 'Project',
'Client',
'Status', 'Status',
'Visibility', 'Visibility',
'Billability', 'Billability',
'Task',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); 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,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape(''); $reader->setEscape('');
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([ $userId = $this->userImportHelper->getKey([
@@ -72,7 +71,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]); ]);
$member = $this->memberImportHelper->getModelById($memberId); $member = $this->memberImportHelper->getModelById($memberId);
$clientId = null; $clientId = null;
if (($record['Client'] ?? '') !== '') { if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([ $clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'], 'name' => $record['Client'],
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -97,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]); ]);
} }
$taskId = null; $taskId = null;
if ($taskKey !== null && $record[$taskKey] !== '') { if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
]); ]);
@@ -117,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;
@@ -215,11 +212,14 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{ {
$requiredFields = [ $requiredFields = [
'Project', 'Project',
'Client',
'Description', 'Description',
'Task',
'User', 'User',
'Group', 'Group',
'Email', 'Email',
'Tags', 'Tags',
'Billable',
'Start Date', 'Start Date',
'Start Time', 'Start Time',
'End Date', 'End Date',
@@ -230,26 +230,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); 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] #[\Override]

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\TagMatchType;
use App\Models\Member; use App\Models\Member;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -63,7 +62,7 @@ class TimeEntryFilter
if ($start === null) { if ($start === null) {
return $this; return $this;
} }
$this->builder->where('start', '>=', $start); $this->builder->where('start', '>', $start);
return $this; return $this;
} }
@@ -193,21 +192,15 @@ class TimeEntryFilter
/** /**
* @param array<string>|null $tagIds * @param array<string>|null $tagIds
*/ */
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self public function addTagIdsFilter(?array $tagIds): self
{ {
if ($tagIds === null) { if ($tagIds === null) {
return $this; return $this;
} }
$tagMatchType ??= TagMatchType::Contains;
$includeNone = in_array(self::NONE_VALUE, $tagIds, true); $includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE)); $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;
}
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void { $this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) { foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId); $builder->orWhereJsonContains('tags', $tagId);
} }
@@ -216,18 +209,7 @@ class TimeEntryFilter
$query->whereJsonLength('tags', 0)->orWhereNull('tags'); $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; return $this;
} }

View File

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

View File

@@ -18,8 +18,8 @@
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1", "korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0", "korridor/laravel-model-validation-rules": "^3.0",
"laravel/fortify": "^1.37",
"laravel/framework": "^12.19.3", "laravel/framework": "^12.19.3",
"laravel/jetstream": "^5.0",
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^13.0.5", "laravel/passport": "^13.0.5",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
@@ -27,7 +27,6 @@
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3", "league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"mobiledetect/mobiledetectlib": "^4.11",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^12.0.4", "nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^14.0.0", "owen-it/laravel-auditing": "^14.0.0",
@@ -132,8 +131,7 @@
"pestphp/pest-plugin": true, "pestphp/pest-plugin": true,
"php-http/discovery": true, "php-http/discovery": true,
"wikimedia/composer-merge-plugin": true "wikimedia/composer-merge-plugin": true
}, }
"process-timeout": 900
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "897ca7bc13f827db641f7affa54a8523", "content-hash": "4c728f01d2beb426b2d157143618fdae",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -4413,6 +4413,72 @@
}, },
"time": "2026-05-20T11:48:19+00:00" "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", "name": "laravel/octane",
"version": "v2.17.4", "version": "v2.17.4",
@@ -6379,16 +6445,16 @@
}, },
{ {
"name": "mobiledetect/mobiledetectlib", "name": "mobiledetect/mobiledetectlib",
"version": "4.11.0", "version": "4.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/serbanghita/Mobile-Detect.git", "url": "https://github.com/serbanghita/Mobile-Detect.git",
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4" "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/ab39168b7556f44c11c80be1222b44b239f5c2e4", "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4", "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6431,7 +6497,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/serbanghita/Mobile-Detect/issues", "issues": "https://github.com/serbanghita/Mobile-Detect/issues",
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.11.0" "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.10.0"
}, },
"funding": [ "funding": [
{ {
@@ -6439,7 +6505,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-05-24T12:32:40+00:00" "time": "2026-04-23T13:05:57+00:00"
}, },
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
@@ -13737,16 +13803,16 @@
}, },
{ {
"name": "web-auth/webauthn-lib", "name": "web-auth/webauthn-lib",
"version": "5.3.5", "version": "5.3.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git", "url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -13807,7 +13873,7 @@
"webauthn" "webauthn"
], ],
"support": { "support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3"
}, },
"funding": [ "funding": [
{ {
@@ -13819,7 +13885,7 @@
"type": "patreon" "type": "patreon"
} }
], ],
"time": "2026-05-31T15:00:08+00:00" "time": "2026-05-17T19:04:30+00:00"
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",

View File

@@ -12,6 +12,7 @@ use App\Providers\AuthServiceProvider;
use App\Providers\EventServiceProvider; use App\Providers\EventServiceProvider;
use App\Providers\Filament\AdminPanelProvider; use App\Providers\Filament\AdminPanelProvider;
use App\Providers\FortifyServiceProvider; use App\Providers\FortifyServiceProvider;
use App\Providers\JetstreamServiceProvider;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -81,8 +82,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
@@ -204,6 +203,7 @@ return [
AdminPanelProvider::class, AdminPanelProvider::class,
RouteServiceProvider::class, RouteServiceProvider::class,
FortifyServiceProvider::class, FortifyServiceProvider::class,
JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider // Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class, LaravelModulesServiceProvider::class,
])->toArray(), ])->toArray(),

82
config/jetstream.php Normal file
View File

@@ -0,0 +1,82 @@
<?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); $profilePhoto = $this->faker->image(null, 500, 500);
/** @see FileHelpers::hashName */ /** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png'; $path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto); Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
return $this->state(function (array $attributes) use ($path): array { return $this->state(function (array $attributes) use ($path): array {
return [ return [

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.58.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

@@ -189,9 +189,7 @@ ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \ WITH_SCHEDULER=false \
WITH_REVERB=false WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
RUN test -z "$(find . -name .git -print -quit)"
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public #COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \ RUN mkdir -p \

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