Compare commits

..

6 Commits

Author SHA1 Message Date
Gregor Vostrak
4623697f79 add feedback button in sidebar 2025-10-01 13:10:29 +02:00
Gregor Vostrak
1c787a0ad0 clarify UserSettingsIcon Dropdown Profile Settings Item Description 2025-10-01 12:15:46 +02:00
Gregor Vostrak
5e678edb9d remove bottom padding for toast container
This became redundant due to the floating feedback bubble removal
2025-09-30 16:58:24 +02:00
Gregor Vostrak
65e145b20f improve focus states and keyboard navigation for organization switcher and user settings dropdown 2025-09-30 16:45:48 +02:00
Gregor Vostrak
d2b636842a update organization switcher to use shadcn dropdownmenu 2025-09-30 16:28:17 +02:00
Gregor Vostrak
1510884f3b change profile dropdown to shadcn, add feedback entry 2025-09-30 13:10:55 +02:00
705 changed files with 18722 additions and 52383 deletions

12
.env.ci
View File

@@ -34,12 +34,7 @@ SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
# Mail # Mail
MAIL_MAILER=smtp MAIL_MAILER=log
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="solidtime" MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test" MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
@@ -60,7 +55,4 @@ AUDITING_ENABLED=true
TELESCOPE_ENABLED=false TELESCOPE_ENABLED=false
# Services # Services
GOTENBERG_URL=http://localhost:3000 GOTENBERG_URL=http://0.0.0.0:3000
# Octane
OCTANE_SERVER=frankenphp

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
- 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@v6 uses: actions/setup-node@v4
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@v6 uses: actions/checkout@v4
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'

View File

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

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
# 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@v6 - uses: actions/setup-node@v4
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@v6 uses: actions/checkout@v4
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
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@v6
- name: "Use Node.js"
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Run vitest"
run: npm run test:unit

View File

@@ -10,7 +10,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
- 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@v6 uses: actions/setup-node@v4
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@v6 uses: actions/checkout@v4
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
- 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@v6 - uses: actions/setup-node@v4
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.4.3
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -9,9 +9,9 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: "Check code style" - name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6 uses: aglipanci/laravel-pint-action@2.5
with: with:
configPath: "pint.json" configPath: "pint.json"

View File

@@ -6,18 +6,10 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
services: services:
mailpit: mailpit:
image: 'axllent/mailpit:latest' image: 'axllent/mailpit:latest'
ports:
- 1025:1025
- 8025:8025
pgsql_test: pgsql_test:
image: postgres:15 image: postgres:15
env: env:
@@ -35,10 +27,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
@@ -65,63 +57,22 @@ jobs:
- name: "Build Frontend" - name: "Build Frontend"
run: npm run build run: npm run build
- name: "Install FrankenPHP" - name: "Run Laravel Server"
run: | run: php artisan serve > /dev/null 2>&1 &
ARCH="$(uname -m)"
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
chmod +x /usr/local/bin/frankenphp
- name: "Run Laravel Octane Server"
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
env:
OCTANE_SERVER: frankenphp
- name: "Install Playwright Browsers" - name: "Install Playwright Browsers"
run: npx playwright install --with-deps run: npx playwright install --with-deps
- name: "Run Playwright tests" - name: "Run Playwright tests"
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} run: npx playwright test
env: env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000' PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
MAILPIT_BASE_URL: 'http://localhost:8025'
- name: "Upload blob report" - name: "Upload test results"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: blob-report-${{ matrix.shardIndex }} name: test-results
path: blob-report/ path: test-results/
retention-days: 7
merge-reports:
if: always()
needs: [test]
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Install dependencies"
run: npm ci
- name: "Download blob reports"
uses: actions/download-artifact@v8
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: "Merge reports"
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: "Upload merged HTML report"
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/
retention-days: 30 retention-days: 30

1
.gitignore vendored
View File

@@ -42,4 +42,3 @@ yarn-error.log
/data /data
/config/caddy /config/caddy
/config/composer /config/composer
/AGENTS.md

1
.npmrc
View File

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

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source TimeTracker # solidtime - The modern Open-Source Time Tracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md) [![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime) [![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)
@@ -37,8 +37,6 @@ If you have a **feature request**, please [**create a discussion**](https://gith
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons. Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,27 +22,13 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens')) ->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes(); ->everyTenMinutes();
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) { $schedule->command('self-host:check-for-update')
// Convert string to a stable integer for seeding ->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
/** @var int $seed Take the first 8 hex chars → 32-bit int */ ->twiceDaily();
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
$seed = abs($seed); // Ensure it's positive
mt_srand($seed);
$firstHour = mt_rand(0, 23);
$secondHour = ($firstHour + 12) % 24;
$minuteOffset = mt_rand(0, 59);
mt_srand(null); // Reset the random number generator
if (config('scheduling.tasks.self_hosting_check_for_update')) { $schedule->command('self-host:telemetry')
$schedule->command('self-host:check-for-update') ->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDailyAt($firstHour, $secondHour, $minuteOffset); ->twiceDaily();
}
if (config('scheduling.tasks.self_hosting_telemetry')) {
$schedule->command('self-host:telemetry')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
}
$schedule->command('self-host:database-consistency') $schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))

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

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

View File

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

View File

@@ -1,31 +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 AddingTeamMember event.
*/
class MemberAdding
{
use Dispatchable;
public User $user;
public Organization $organization;
public Role $role;
public function __construct(User $user, Organization $organization, Role $role)
{
$this->user = $user;
$this->organization = $organization;
$this->role = $role;
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager class InvitationsRelationManager extends RelationManager
{ {
protected static string $relationship = 'organizationInvitations'; protected static string $relationship = 'teamInvitations';
protected static ?string $title = 'Invitations'; protected static ?string $title = 'Invitations';
@@ -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

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

View File

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

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages; use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\Member;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@@ -17,7 +16,6 @@ use Filament\Tables;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TimeEntryResource extends Resource class TimeEntryResource extends Resource
{ {
@@ -53,23 +51,15 @@ class TimeEntryResource extends Resource
->rules([ ->rules([
'after_or_equal:start', 'after_or_equal:start',
]), ]),
Select::make('member_id') Select::make('user_id')
->relationship( ->relationship(name: 'user', titleAttribute: 'email')
name: 'member', ->searchable(['name', 'email'])
titleAttribute: 'id',
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
)
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
->searchable()
->required(), ->required(),
Select::make('project_id') Select::make('project_id')
->relationship(name: 'project', titleAttribute: 'name') ->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
->nullable(), ->nullable(),
Select::make('task_id') // TODO
->relationship(name: 'task', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
]); ]);
} }
@@ -93,11 +83,11 @@ class TimeEntryResource extends Resource
($record->end?->toDateTimeString('minute') ?? '...').')'; ($record->end?->toDateTimeString('minute') ?? '...').')';
}) })
->label('Time'), ->label('Time'),
TextColumn::make('organization.name') Tables\Columns\TextColumn::make('organization.name')
->sortable(), ->sortable(),
TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->sortable(), ->sortable(),
TextColumn::make('updated_at') Tables\Columns\TextColumn::make('updated_at')
->sortable(), ->sortable(),
]) ])
->filters([ ->filters([

View File

@@ -5,28 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages; namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateTimeEntry extends CreateRecord class CreateTimeEntry extends CreateRecord
{ {
protected static string $resource = TimeEntryResource::class; protected static string $resource = TimeEntryResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages; namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
@@ -20,22 +19,4 @@ class EditTimeEntry extends EditRecord
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
} }

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
/** /**
* List all api token of the currently authenticated user * List all api token of the currently authenticated user
* *
* This endpoint is independent of the organization. * This endpoint is independent of organization.
* *
* @operationId getApiTokens * @operationId getApiTokens
* *
@@ -35,7 +35,6 @@ class ApiTokenController extends Controller
/** @var Builder<Client> $query */ /** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access'); $query->whereJsonContains('grant_types', 'personal_access');
}) })
->orderBy('created_at', 'desc')
->get(); ->get();
return new ApiTokenCollection($tokens); return new ApiTokenCollection($tokens);

View File

@@ -102,7 +102,7 @@ class ChartController extends Controller
$this->checkPermission($organization, 'charts:view:own'); $this->checkPermission($organization, 'charts:view:own');
$user = $this->user(); $user = $this->user();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100); $dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
return response()->json($dailyTrackedHours); return response()->json($dailyTrackedHours);
} }

View File

@@ -38,17 +38,10 @@ class ClientController extends Controller
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{ {
$this->checkPermission($organization, 'clients:view'); $this->checkPermission($organization, 'clients:view');
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
$user = $this->user();
$clientsQuery = Client::query() $clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc');
->orderBy('id');
if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived(); $filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') { if ($filterArchived === 'true') {

View File

@@ -40,9 +40,7 @@ class InvitationController extends Controller
{ {
$this->checkPermission($organization, 'invitations:view'); $this->checkPermission($organization, 'invitations:view');
$invitations = $organization->organizationInvitations() $invitations = $organization->teamInvitations()
->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 +62,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

@@ -60,8 +60,6 @@ class MemberController extends Controller
$members = Member::query() $members = Member::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->with(['user']) ->with(['user'])
->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 +191,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

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

View File

@@ -60,10 +60,7 @@ class ProjectController extends Controller
$projectsQuery->whereNull('archived_at'); $projectsQuery->whereNull('archived_at');
} }
$projects = $projectsQuery $projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
@@ -79,7 +76,7 @@ class ProjectController extends Controller
*/ */
public function show(Organization $organization, Project $project): JsonResource public function show(Organization $organization, Project $project): JsonResource
{ {
$this->checkPermission($organization, 'projects:view:all', $project); $this->checkPermission($organization, 'projects:view', $project);
// Note: There is currently no need to check if a user is a member of the project, // Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint. // since this is only relevant for users with the role "employee" and they can not access this endpoint.

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection; use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
@@ -42,14 +41,12 @@ class ProjectMemberController extends Controller
* *
* @operationId getProjectMembers * @operationId getProjectMembers
*/ */
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection public function index(Organization $organization, Project $project): ProjectMemberCollection
{ {
$this->checkPermission($organization, 'project-members:view', $project); $this->checkPermission($organization, 'project-members:view', $project);
$projectMembers = ProjectMember::query() $projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->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

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\Report\ReportIndexRequest;
use App\Http\Requests\V1\Report\ReportStoreRequest; use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest; use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource; use App\Http\Resources\V1\Report\DetailedReportResource;
@@ -41,13 +40,12 @@ class ReportController extends Controller
* *
* @operationId getReports * @operationId getReports
*/ */
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection public function index(Organization $organization): ReportCollection
{ {
$this->checkPermission($organization, 'reports:view'); $this->checkPermission($organization, 'reports:view');
$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'));
@@ -152,9 +150,6 @@ class ReportController extends Controller
$report->share_secret = null; $report->share_secret = null;
$report->public_until = null; $report->public_until = null;
} }
} elseif ($report->is_public && $request->has('public_until')) {
// Allow updating expiration date on already-public reports
$report->public_until = $request->getPublicUntil();
} }
$report->save(); $report->save();

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagIndexRequest;
use App\Http\Requests\V1\Tag\TagStoreRequest; use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest; use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Resources\V1\Tag\TagCollection; use App\Http\Resources\V1\Tag\TagCollection;
@@ -35,14 +34,13 @@ class TagController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function index(Organization $organization, TagIndexRequest $request): TagCollection public function index(Organization $organization): TagCollection
{ {
$this->checkPermission($organization, 'tags:view'); $this->checkPermission($organization, 'tags:view');
$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

@@ -11,7 +11,6 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
use App\Http\Resources\V1\Task\TaskCollection; use App\Http\Resources\V1\Task\TaskCollection;
use App\Http\Resources\V1\Task\TaskResource; use App\Http\Resources\V1\Task\TaskResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -28,26 +27,6 @@ class TaskController extends Controller
} }
} }
/**
* Check scoped permission and verify user has access to the project
*
* @throws AuthorizationException
*/
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
{
$this->checkPermission($organization, $permission);
$user = $this->user();
$hasAccess = Project::query()
->where('id', $project->id)
->visibleByEmployee($user)
->exists();
if (! $hasAccess) {
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
}
}
/** /**
* Get tasks * Get tasks
* *
@@ -82,10 +61,7 @@ class TaskController extends Controller
$query->whereNull('done_at'); $query->whereNull('done_at');
} }
$tasks = $query $tasks = $query->paginate(config('app.pagination_per_page_default'));
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks); return new TaskCollection($tasks);
} }
@@ -99,15 +75,7 @@ class TaskController extends Controller
*/ */
public function store(Organization $organization, TaskStoreRequest $request): JsonResource public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{ {
/** @var Project $project */ $this->checkPermission($organization, 'tasks:create');
$project = Project::query()->findOrFail($request->input('project_id'));
if ($this->hasPermission($organization, 'tasks:create:all')) {
$this->checkPermission($organization, 'tasks:create:all');
} else {
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
}
$task = new Task; $task = new Task;
$task->name = $request->input('name'); $task->name = $request->input('name');
$task->project_id = $request->input('project_id'); $task->project_id = $request->input('project_id');
@@ -129,17 +97,7 @@ class TaskController extends Controller
*/ */
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{ {
// Check task belongs to organization $this->checkPermission($organization, 'tasks:update', $task);
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:update:all')) {
$this->checkPermission($organization, 'tasks:update:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
}
$task->name = $request->input('name'); $task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime(); $task->estimated_time = $request->getEstimatedTime();
@@ -161,16 +119,7 @@ class TaskController extends Controller
*/ */
public function destroy(Organization $organization, Task $task): JsonResponse public function destroy(Organization $organization, Task $task): JsonResponse
{ {
// Check task belongs to organization $this->checkPermission($organization, 'tasks:delete', $task);
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:delete:all')) {
$this->checkPermission($organization, 'tasks:delete:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
}
if ($task->timeEntries()->exists()) { if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry'); throw new EntityStillInUseApiException('task', 'time_entry');

View File

@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -46,56 +45,17 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File; use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller class TimeEntryController extends Controller
{ {
private function assertNoOverlap(Organization $organization, Member $member, Carbon $start, ?Carbon $end, ?TimeEntry $exclude = null): void
{
if (! $organization->prevent_overlapping_time_entries) {
return;
}
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
->where(function (Builder $q) use ($start, $end): void {
$q->where(function (Builder $q2) use ($start): void {
$q2->where('end', '>', $start)
->where('start', '<', $start);
});
if ($end !== null) {
$q->orWhere(function (Builder $q4) use ($end): void {
$q4->where('start', '<', $end)
->where('end', '>', $end);
});
// Check if the new entry completely surrounds an existing entry
$q->orWhere(function (Builder $q6) use ($start, $end): void {
$q6->where('start', '>=', $start)
->where('end', '<=', $end);
});
}
});
if ($query->exists()) {
throw new OverlappingTimeEntryApiException;
}
}
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
{ {
parent::checkPermission($organization, $permission); parent::checkPermission($organization, $permission);
@@ -194,8 +154,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'));
@@ -248,7 +207,7 @@ class TimeEntryController extends Controller
'user', 'user',
'tagsRelation', 'tagsRelation',
]); ]);
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension(); $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization); $localizationService = LocalizationService::forOrganization($organization);
@@ -471,7 +430,7 @@ class TimeEntryController extends Controller
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$localizationService = LocalizationService::forOrganization($organization); $localizationService = LocalizationService::forOrganization($organization);
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension(); $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;
@@ -590,15 +549,17 @@ class TimeEntryController extends Controller
throw new TimeEntryStillRunningApiException; throw new TimeEntryStillRunningApiException;
} }
// Overlap check for create
$start = Carbon::parse($request->input('start'));
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
$this->assertNoOverlap($organization, $member, $start, $end);
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null; $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client; $client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null; $task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
$timeEntry->client()->associate($client); $timeEntry->client()->associate($client);
@@ -608,13 +569,6 @@ class TimeEntryController extends Controller
$timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry); return new TimeEntryResource($timeEntry);
} }
@@ -630,22 +584,15 @@ class TimeEntryController extends Controller
/** @var Member|null $member */ /** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) { if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry); $this->checkPermission($organization, 'time-entries:update:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry); $this->checkPermission($organization, 'time-entries:update:all');
} }
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
throw new TimeEntryCanNotBeRestartedApiException; throw new TimeEntryCanNotBeRestartedApiException;
} }
// Overlap check for update (exclude current)
/** @var Member $effectiveMember */
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
$oldProject = $timeEntry->project; $oldProject = $timeEntry->project;
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

@@ -17,7 +17,7 @@ class EnsureEmailIsVerified
*/ */
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response 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

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

View File

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

View File

@@ -42,11 +42,11 @@ class HandleInertiaRequests extends Middleware
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services'); $hasServices = Module::has('Services') && Module::isEnabled('Services');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
$currentOrganization = $request->user()?->currentOrganization; $currentOrganization = $request->user()?->currentTeam;
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
@@ -60,8 +60,6 @@ class HandleInertiaRequests extends Middleware
] : null, ] : null,
'flash' => [ 'flash' => [
'message' => fn () => $request->session()->get('message'), 'message' => fn () => $request->session()->get('message'),
'bannerText' => fn () => $request->session()->get('bannerText'),
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
], ],
]); ]);
} }

View File

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

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,28 @@ 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()),
'flash' => $request->session()->get('flash', []),
'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 +57,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 +69,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

@@ -21,11 +21,6 @@ class InvitationIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
]; ];
} }
} }

View File

@@ -21,11 +21,6 @@ class MemberIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
]; ];
} }
} }

View File

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

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

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

View File

@@ -11,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',
@@ -45,12 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [ 'employees_can_see_billable_rates' => [
'boolean', 'boolean',
], ],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
'number_format' => [ 'number_format' => [
Rule::enum(NumberFormat::class), Rule::enum(NumberFormat::class),
], ],
@@ -74,11 +62,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;
@@ -115,14 +98,4 @@ class OrganizationUpdateRequest extends BaseFormRequest
{ {
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null; return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
} }
public function getEmployeesCanManageTasks(): ?bool
{
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
} }

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ProjectMemberIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ReportIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -10,11 +10,9 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule; use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** /**
@@ -25,7 +23,7 @@ class ReportStoreRequest 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|ValidationRule|LegacyValidationRule|\Closure>> * @return array<string, array<string|ValidationRule|LegacyValidationRule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -83,14 +81,7 @@ class ReportStoreRequest extends BaseFormRequest
], ],
'properties.client_ids.*' => [ 'properties.client_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { 'uuid',
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
], ],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'properties.project_ids' => [ 'properties.project_ids' => [
@@ -99,14 +90,7 @@ class ReportStoreRequest extends BaseFormRequest
], ],
'properties.project_ids.*' => [ 'properties.project_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { 'uuid',
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
], ],
// Filter by tag IDs, tag IDs are OR combined // Filter by tag IDs, tag IDs are OR combined
'properties.tag_ids' => [ 'properties.tag_ids' => [
@@ -115,14 +99,7 @@ class ReportStoreRequest extends BaseFormRequest
], ],
'properties.tag_ids.*' => [ 'properties.tag_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { 'uuid',
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
], ],
'properties.task_ids' => [ 'properties.task_ids' => [
'nullable', 'nullable',
@@ -130,14 +107,7 @@ class ReportStoreRequest extends BaseFormRequest
], ],
'properties.task_ids.*' => [ 'properties.task_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { 'uuid',
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
], ],
'properties.group' => [ 'properties.group' => [
'required', 'required',

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class TagIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -26,11 +26,6 @@ class TaskIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
'project_id' => [ 'project_id' => [
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */

View File

@@ -16,7 +16,6 @@ use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -31,7 +30,7 @@ class TimeEntryAggregateExportRequest 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|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>> * @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -95,15 +94,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Project> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by client IDs, client IDs are OR combined // Filter by client IDs, client IDs are OR combined
'client_ids' => [ 'client_ids' => [
@@ -112,15 +106,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
], ],
'client_ids.*' => [ 'client_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Client> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by tag IDs, tag IDs are OR combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
@@ -129,15 +118,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Tag> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -146,14 +130,9 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { return $builder->whereBelongsTo($this->organization, 'organization');
return; })->uuid(),
}
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [

View File

@@ -14,7 +14,6 @@ use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -29,7 +28,7 @@ class TimeEntryAggregateRequest 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|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>> * @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -81,15 +80,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Project> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by client IDs, client IDs are OR combined // Filter by client IDs, client IDs are OR combined
'client_ids' => [ 'client_ids' => [
@@ -98,15 +92,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
], ],
'client_ids.*' => [ 'client_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Client> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by tag IDs, tag IDs are OR combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
@@ -115,15 +104,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Tag> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -132,14 +116,9 @@ class TimeEntryAggregateRequest extends BaseFormRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { return $builder->whereBelongsTo($this->organization, 'organization');
return; })->uuid(),
}
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [

View File

@@ -6,13 +6,11 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -27,7 +25,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>> * @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -59,23 +57,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), }),
], ],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'project_ids' => [ 'project_ids' => [
'array', 'array',
@@ -83,15 +64,11 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { 'uuid',
if ($value === TimeEntryFilter::NONE_VALUE) { new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
return; /** @var Builder<Project> $builder */
} return $builder->whereBelongsTo($this->organization, 'organization');
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { }),
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by tag IDs, tag IDs are OR combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
@@ -100,15 +77,11 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { 'uuid',
if ($value === TimeEntryFilter::NONE_VALUE) { new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
return; /** @var Builder<Tag> $builder */
} return $builder->whereBelongsTo($this->organization, 'organization');
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { }),
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -117,15 +90,11 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { 'uuid',
if ($value === TimeEntryFilter::NONE_VALUE) { new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
return; /** @var Builder<Task> $builder */
} return $builder->whereBelongsTo($this->organization, 'organization');
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { }),
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [

View File

@@ -12,7 +12,6 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\Rule as RuleContract; use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -27,7 +26,7 @@ class TimeEntryIndexRequest 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|ValidationRule|RuleContract|\Closure>> * @return array<string, array<string|ValidationRule|RuleContract>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -59,15 +58,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
], ],
'client_ids.*' => [ 'client_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Client> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'project_ids' => [ 'project_ids' => [
@@ -76,15 +70,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Project> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by tag IDs, tag IDs are OR combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
@@ -93,15 +82,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Tag> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -110,15 +94,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
function (string $attribute, mixed $value, \Closure $fail): void { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
if ($value === TimeEntryFilter::NONE_VALUE) { /** @var Builder<Task> $builder */
return; return $builder->whereBelongsTo($this->organization, 'organization');
} })->uuid(),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -44,16 +42,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
@@ -90,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'description' => [ 'description' => [
'nullable', 'nullable',
'string', 'string',
'max:5000', 'max:500',
], ],
// List of tag IDs // List of tag IDs
'tags' => [ 'tags' => [

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -56,16 +54,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
@@ -90,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.description' => [ 'changes.description' => [
'nullable', 'nullable',
'string', 'string',
'max:5000', 'max:500',
], ],
// List of tag IDs // List of tag IDs
'changes.tags' => [ 'changes.tags' => [

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