mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 21:22:15 +01:00
Compare commits
1 Commits
c2a8eac65f
...
feature/em
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec3b732ad |
12
.env.ci
12
.env.ci
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
4
.github/workflows/build-onpremise.yml
vendored
4
.github/workflows/build-onpremise.yml
vendored
@@ -91,7 +91,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'
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|||||||
10
.github/workflows/build-private.yml
vendored
10
.github/workflows/build-private.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v5
|
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
|
||||||
|
|
||||||
@@ -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@v5
|
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@v5
|
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@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-invoicing
|
repository: solidtime-io/extension-invoicing
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
|
|||||||
6
.github/workflows/build-public.yml
vendored
6
.github/workflows/build-public.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v5
|
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
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|||||||
2
.github/workflows/generate-api-docs.yml
vendored
2
.github/workflows/generate-api-docs.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
4
.github/workflows/npm-build.yml
vendored
4
.github/workflows/npm-build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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'
|
||||||
|
|||||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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'
|
||||||
|
|||||||
4
.github/workflows/npm-typecheck.yml
vendored
4
.github/workflows/npm-typecheck.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
6
.github/workflows/phpunit.yml
vendored
6
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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@v5.5.1
|
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
|
||||||
|
|||||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v5
|
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"
|
||||||
|
|||||||
67
.github/workflows/playwright.yml
vendored
67
.github/workflows/playwright.yml
vendored
@@ -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@v5
|
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@v4
|
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@v4
|
|
||||||
|
|
||||||
- name: "Setup node"
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20.x'
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: "Download blob reports"
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
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@v4
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,4 +42,3 @@ yarn-error.log
|
|||||||
/data
|
/data
|
||||||
/config/caddy
|
/config/caddy
|
||||||
/config/composer
|
/config/composer
|
||||||
/AGENTS.md
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# solidtime - The modern Open-Source TimeTracker
|
# solidtime - The modern Open-Source Time Tracker
|
||||||
|
|
||||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
[](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.
|
||||||
|
|||||||
15
SECURITY.md
15
SECURITY.md
@@ -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)
|
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Mail\VerifyUpdatedEmailMail;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
@@ -27,10 +24,6 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
if (isset($input['email']) && is_string($input['email'])) {
|
|
||||||
$input['email'] = Str::lower($input['email']);
|
|
||||||
}
|
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => [
|
'name' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -65,17 +58,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
$user->updateProfilePhoto($input['photo']);
|
$user->updateProfilePhoto($input['photo']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$email = Str::lower((string) $input['email']);
|
if ($input['email'] !== $user->email) {
|
||||||
|
|
||||||
if ($email !== Str::lower($user->email)) {
|
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'pending_email' => $email,
|
'email' => $input['email'],
|
||||||
|
'email_verified_at' => null,
|
||||||
'timezone' => $input['timezone'],
|
'timezone' => $input['timezone'],
|
||||||
'week_start' => $input['week_start'],
|
'week_start' => $input['week_start'],
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
|
$user->sendEmailVerificationNotification();
|
||||||
} else {
|
} else {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Exceptions\MovedToApiException;
|
use App\Enums\Role;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
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;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
|
|
||||||
class AddOrganizationMember implements AddsTeamMembers
|
class AddOrganizationMember implements AddsTeamMembers
|
||||||
@@ -16,6 +25,70 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
*/
|
*/
|
||||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
{
|
{
|
||||||
throw new MovedToApiException;
|
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.')
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ class CreateOrganization implements CreatesTeams
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
*/
|
||||||
public function create(User $user, array $input): Organization
|
public function create(User $user, array $input): Organization
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ class DeleteOrganization implements DeletesTeams
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Delete the given team.
|
* Delete the given team.
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
*/
|
||||||
public function delete(Organization $organization): void
|
public function delete(Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ class DeleteUser implements DeletesUsers
|
|||||||
* Delete the given user.
|
* Delete the given user.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
*/
|
||||||
public function delete(User $user): void
|
public function delete(User $user): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ class ValidateOrganizationDeletion
|
|||||||
* @param Organization $organization Organization to be deleted
|
* @param Organization $organization Organization to be deleted
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
|
||||||
* @deprecated Use REST endpoint instead
|
|
||||||
*/
|
*/
|
||||||
public function validate(User $user, Organization $organization): void
|
public function validate(User $user, Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,28 +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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class InvitationController extends Controller
|
|||||||
$this->checkPermission($organization, 'invitations:view');
|
$this->checkPermission($organization, 'invitations:view');
|
||||||
|
|
||||||
$invitations = $organization->teamInvitations()
|
$invitations = $organization->teamInvitations()
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
|
|||||||
@@ -60,7 +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')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
|
|||||||
@@ -5,17 +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\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 Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class OrganizationController extends Controller
|
class OrganizationController extends Controller
|
||||||
{
|
{
|
||||||
@@ -86,48 +80,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
|
|
||||||
);
|
|
||||||
|
|
||||||
$user->switchTeam($organization);
|
|
||||||
|
|
||||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
|
||||||
$organization = $organization->refresh();
|
|
||||||
AfterCreateOrganization::dispatch($organization);
|
|
||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete organization
|
|
||||||
*
|
|
||||||
* @operationId deleteOrganization
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'organizations:delete');
|
|
||||||
|
|
||||||
$deletionService->deleteOrganization($organization);
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +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')
|
|
||||||
->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;
|
||||||
|
|
||||||
@@ -78,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.
|
||||||
|
|||||||
@@ -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,13 +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')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -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,7 +40,7 @@ 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');
|
||||||
|
|
||||||
@@ -151,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +34,7 @@ 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');
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,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')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
|
||||||
|
|
||||||
return new TaskCollection($tasks);
|
return new TaskCollection($tasks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ 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;
|
||||||
|
|
||||||
@@ -247,7 +246,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);
|
||||||
@@ -470,7 +469,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;
|
||||||
|
|
||||||
@@ -629,9 +628,9 @@ 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) {
|
||||||
|
|||||||
@@ -4,26 +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\UserUpdateRequest;
|
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
use App\Mail\VerifyUpdatedEmailMail;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\DeletionService;
|
|
||||||
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
|
||||||
*
|
*
|
||||||
@@ -35,114 +24,4 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return new UserResource($user);
|
return new UserResource($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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->getPhoto() !== null) {
|
|
||||||
$photo = Base64File::decode($request->getPhoto());
|
|
||||||
assert($photo !== null);
|
|
||||||
$extension = Base64File::extension($photo['mime_type']);
|
|
||||||
assert($extension !== null);
|
|
||||||
|
|
||||||
$previousPhotoPath = $user->profile_photo_path;
|
|
||||||
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
|
|
||||||
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
|
|
||||||
|
|
||||||
Storage::disk($photoDisk)->put($photoPath, $photo['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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, DeletionService $deletionService): JsonResponse
|
|
||||||
{
|
|
||||||
if ($user->getKey() !== $this->user()->getKey()) {
|
|
||||||
throw new AuthorizationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$deletionService->deleteUser($user);
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +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', [
|
|
||||||
'bannerStyle' => 'danger',
|
|
||||||
'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', [
|
|
||||||
'bannerStyle' => 'success',
|
|
||||||
'bannerText' => __('Your email address has been updated successfully.'),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'),
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class ShareInertiaData
|
|||||||
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
||||||
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
||||||
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
||||||
|
'flash' => $request->session()->get('flash', []),
|
||||||
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
||||||
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
||||||
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ class InvitationIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'page' => [
|
|
||||||
'integer',
|
|
||||||
'min:1',
|
|
||||||
'max:2147483647',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ class MemberIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'page' => [
|
|
||||||
'integer',
|
|
||||||
'min:1',
|
|
||||||
'max:2147483647',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\V1\Organization;
|
|
||||||
|
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
|
||||||
use App\Models\Organization;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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|\Illuminate\Contracts\Validation\Rule>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return (string) $this->input('name');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 TimeEntryUpdateRequest 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
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\V1\User;
|
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Rules\Base64ImageRule;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property User $user User from model binding
|
|
||||||
*/
|
|
||||||
class UserUpdateRequest extends BaseFormRequest
|
|
||||||
{
|
|
||||||
protected function prepareForValidation(): void
|
|
||||||
{
|
|
||||||
if ($this->has('email') && is_string($this->input('email'))) {
|
|
||||||
$this->merge([
|
|
||||||
'email' => Str::lower((string) $this->input('email')),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => [
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
],
|
|
||||||
'email' => [
|
|
||||||
'email',
|
|
||||||
'max:255',
|
|
||||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
|
||||||
/** @var Builder<User> $query */
|
|
||||||
return $query->where('is_placeholder', '=', false);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
'photo' => [
|
|
||||||
'nullable',
|
|
||||||
new Base64ImageRule,
|
|
||||||
],
|
|
||||||
'timezone' => [
|
|
||||||
'timezone:all',
|
|
||||||
],
|
|
||||||
'week_start' => [
|
|
||||||
Rule::enum(Weekday::class),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): ?string
|
|
||||||
{
|
|
||||||
return $this->has('name') ? (string) $this->input('name') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEmail(): ?string
|
|
||||||
{
|
|
||||||
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTimezone(): ?string
|
|
||||||
{
|
|
||||||
return $this->has('timezone') ? (string) $this->input('timezone') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWeekStart(): ?Weekday
|
|
||||||
{
|
|
||||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPhoto(): ?string
|
|
||||||
{
|
|
||||||
return $this->has('photo') ? (string) $this->input('photo') : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Client;
|
namespace App\Http\Resources\V1\Client;
|
||||||
|
|
||||||
use App\Http\Resources\PaginatedResourceCollection;
|
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
class ClientCollection extends ResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Tag;
|
namespace App\Http\Resources\V1\Tag;
|
||||||
|
|
||||||
use App\Http\Resources\PaginatedResourceCollection;
|
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
class TagCollection extends ResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
43
app/Listeners/RemovePlaceholder.php
Normal file
43
app/Listeners/RemovePlaceholder.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\MemberService;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
|
|
||||||
|
class RemovePlaceholder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the event.
|
||||||
|
*/
|
||||||
|
public function handle(TeamMemberAdded $event): void
|
||||||
|
{
|
||||||
|
$memberService = app(MemberService::class);
|
||||||
|
$member = Member::query()
|
||||||
|
->whereBelongsTo($event->team, 'organization')
|
||||||
|
->whereBelongsTo($event->user, 'user')
|
||||||
|
->firstOrFail();
|
||||||
|
$placeholders = Member::query()
|
||||||
|
->whereHas('user', function (Builder $query) use ($event): void {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
$query->where('is_placeholder', '=', true)
|
||||||
|
->where('email', '=', $event->user->email);
|
||||||
|
})
|
||||||
|
->whereBelongsTo($event->team, 'organization')
|
||||||
|
->with(['user'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($placeholders as $placeholder) {
|
||||||
|
/** @var Member $placeholder */
|
||||||
|
$placeholderUser = $placeholder->user;
|
||||||
|
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
||||||
|
$placeholder->delete();
|
||||||
|
$placeholderUser->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ use App\Models\OrganizationInvitation;
|
|||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
class OrganizationInvitationMail extends Mailable
|
class OrganizationInvitationMail extends Mailable
|
||||||
@@ -33,12 +32,9 @@ class OrganizationInvitationMail extends Mailable
|
|||||||
public function build(): self
|
public function build(): self
|
||||||
{
|
{
|
||||||
return $this->markdown('emails.organization-invitation', [
|
return $this->markdown('emails.organization-invitation', [
|
||||||
'acceptUrl' => URL::to(URL::signedRoute(
|
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||||
'organization-invitations.accept',
|
'invitation' => $this->invitation,
|
||||||
['invitation' => $this->invitation->getKey()],
|
]),
|
||||||
Carbon::now()->addDays(90),
|
|
||||||
false
|
|
||||||
)),
|
|
||||||
])->subject(__('Organization Invitation'));
|
])->subject(__('Organization Invitation'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class VerifyUpdatedEmailMail extends Mailable
|
|
||||||
{
|
|
||||||
use Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public User $user;
|
|
||||||
|
|
||||||
public string $email;
|
|
||||||
|
|
||||||
public function __construct(User $user, string $email)
|
|
||||||
{
|
|
||||||
$this->user = $user;
|
|
||||||
$this->email = Str::lower($email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the message.
|
|
||||||
*/
|
|
||||||
public function build(): self
|
|
||||||
{
|
|
||||||
$verificationUrl = URL::temporarySignedRoute(
|
|
||||||
'users.verify-email-change',
|
|
||||||
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
|
|
||||||
[
|
|
||||||
'user' => $this->user->getKey(),
|
|
||||||
'email' => $this->email,
|
|
||||||
],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->markdown('emails.verify-updated-email', [
|
|
||||||
'verificationUrl' => URL::to($verificationUrl),
|
|
||||||
])->subject(__('Verify Email Address'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
* @property bool $employees_can_see_billable_rates
|
||||||
* @property bool $employees_can_manage_tasks
|
* @property bool $employees_can_manage_tasks
|
||||||
* @property bool $prevent_overlapping_time_entries
|
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string $role
|
* @property string $role
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
* @property Carbon|null $accepted_at
|
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
@@ -42,16 +41,14 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
|||||||
protected $table = 'organization_invitations';
|
protected $table = 'organization_invitations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
public function casts(): array
|
protected $fillable = [
|
||||||
{
|
'email',
|
||||||
return [
|
'role',
|
||||||
'accepted_at' => 'datetime',
|
];
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization that the invitation belongs to.
|
* Get the organization that the invitation belongs to.
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $id
|
* @property string $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string|null $pending_email
|
|
||||||
* @property Carbon|null $email_verified_at
|
* @property Carbon|null $email_verified_at
|
||||||
* @property string|null $password
|
* @property string|null $password
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
@@ -106,7 +105,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'name' => 'string',
|
'name' => 'string',
|
||||||
'email' => 'string',
|
'email' => 'string',
|
||||||
'pending_email' => 'string',
|
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
'is_placeholder' => 'boolean',
|
'is_placeholder' => 'boolean',
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Policies;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
@@ -59,7 +58,19 @@ class OrganizationPolicy
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
return $user->ownsTeam($organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can add team members.
|
||||||
|
*/
|
||||||
|
public function addTeamMember(User $user, Organization $organization): bool
|
||||||
|
{
|
||||||
|
if (Filament::isServing()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Listeners\RemovePlaceholder;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -19,6 +21,9 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
|
TeamMemberAdded::class => [
|
||||||
|
RemovePlaceholder::class,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
use Laravel\Fortify\Http\Responses\LoginResponse;
|
use Laravel\Fortify\Http\Responses\LoginResponse;
|
||||||
@@ -42,14 +41,6 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||||
|
|
||||||
Fortify::registerView(function () {
|
|
||||||
return Inertia::render('Auth/Register', [
|
|
||||||
'terms_url' => config('auth.terms_url'),
|
|
||||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
|
||||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
|
|||||||
@@ -13,18 +13,20 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
|
|||||||
use App\Actions\Jetstream\UpdateMemberRole;
|
use App\Actions\Jetstream\UpdateMemberRole;
|
||||||
use App\Actions\Jetstream\UpdateOrganization;
|
use App\Actions\Jetstream\UpdateOrganization;
|
||||||
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Brick\Money\Currency;
|
use Brick\Money\Currency;
|
||||||
use Brick\Money\ISOCurrencyProvider;
|
use Brick\Money\ISOCurrencyProvider;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
||||||
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
@@ -58,6 +60,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||||
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
||||||
|
Fortify::registerView(function () {
|
||||||
|
return Inertia::render('Auth/Register', [
|
||||||
|
'terms_url' => config('auth.terms_url'),
|
||||||
|
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||||
|
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -70,10 +79,205 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
Jetstream::defaultApiTokenPermissions([]);
|
Jetstream::defaultApiTokenPermissions([]);
|
||||||
|
|
||||||
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
|
Jetstream::role(Role::Owner->value, 'Owner', [
|
||||||
Jetstream::role($role, $definition['name'], $definition['permissions'])
|
'charts:view:own',
|
||||||
->description($definition['description']);
|
'charts:view:all',
|
||||||
}
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'organizations:update',
|
||||||
|
'organizations:delete',
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
'invitations:view',
|
||||||
|
'invitations:create',
|
||||||
|
'invitations:resend',
|
||||||
|
'invitations:remove',
|
||||||
|
'members:view',
|
||||||
|
'members:invite-placeholder',
|
||||||
|
'members:change-ownership',
|
||||||
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
|
'members:update',
|
||||||
|
'members:delete',
|
||||||
|
'billing',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||||
|
|
||||||
|
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'organizations:update',
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
'invitations:view',
|
||||||
|
'invitations:create',
|
||||||
|
'invitations:resend',
|
||||||
|
'invitations:remove',
|
||||||
|
'members:view',
|
||||||
|
'members:invite-placeholder',
|
||||||
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
|
'members:delete',
|
||||||
|
'members:update',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||||
|
|
||||||
|
Jetstream::role(Role::Manager->value, 'Manager', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'invitations:view',
|
||||||
|
'members:view',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
||||||
|
|
||||||
|
Jetstream::role(Role::Employee->value, 'Employee', [
|
||||||
|
'charts:view:own',
|
||||||
|
'projects:view',
|
||||||
|
'tags:view',
|
||||||
|
'tasks:view',
|
||||||
|
'clients:view',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'organizations:view',
|
||||||
|
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
||||||
|
|
||||||
|
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||||
|
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||||
|
|
||||||
Jetstream::inertia()
|
Jetstream::inertia()
|
||||||
->whenRendering(
|
->whenRendering(
|
||||||
@@ -100,8 +304,28 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'owner' => [
|
'owner' => [
|
||||||
'id' => $owner->getKey(),
|
'id' => $owner->getKey(),
|
||||||
'name' => $owner->name,
|
'name' => $owner->name,
|
||||||
|
'email' => $owner->email,
|
||||||
'profile_photo_url' => $owner->profile_photo_url,
|
'profile_photo_url' => $owner->profile_photo_url,
|
||||||
],
|
],
|
||||||
|
'users' => $teamModel->users->map(function (User $user): array {
|
||||||
|
return [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'profile_photo_url' => $user->profile_photo_url,
|
||||||
|
'membership' => [
|
||||||
|
'id' => $user->membership->id,
|
||||||
|
'role' => $user->membership->role,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
|
||||||
|
return [
|
||||||
|
'id' => $invitation->getKey(),
|
||||||
|
'email' => $invitation->email,
|
||||||
|
'role' => $invitation->role,
|
||||||
|
];
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
'currencies' => array_map(function (Currency $currency): string {
|
'currencies' => array_map(function (Currency $currency): string {
|
||||||
return $currency->getName();
|
return $currency->getName();
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Rules;
|
|
||||||
|
|
||||||
use App\Support\Base64File;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
|
||||||
|
|
||||||
class Base64ImageRule implements ValidationRule
|
|
||||||
{
|
|
||||||
private const array ALLOWED_MIME_TYPES = [
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the validation rule.
|
|
||||||
*
|
|
||||||
* @param Closure(string): PotentiallyTranslatedString $fail
|
|
||||||
*/
|
|
||||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
||||||
{
|
|
||||||
if (! is_string($value)) {
|
|
||||||
$fail(__('validation.string'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = Base64File::decode($value);
|
|
||||||
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
|
|
||||||
$fail(__('validation.mimes', ['values' => 'jpg, png']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -266,8 +266,7 @@ class DashboardService
|
|||||||
) as aggregate'))
|
) as aggregate'))
|
||||||
->where('billable', '=', true)
|
->where('billable', '=', true)
|
||||||
->whereNotNull('billable_rate')
|
->whereNotNull('billable_rate')
|
||||||
->where('user_id', '=', $user->getKey())
|
->where('user_id', '=', $user->id);
|
||||||
->where('organization_id', '=', $organization->getKey());
|
|
||||||
|
|
||||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Enums\TimeEntryAggregationType;
|
|||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Service\TimeEntryFilter;
|
|
||||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -175,7 +174,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
if (! is_string($id)) {
|
if (! is_string($id)) {
|
||||||
throw new \InvalidArgumentException('The given ID is not a string');
|
throw new \InvalidArgumentException('The given ID is not a string');
|
||||||
}
|
}
|
||||||
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
|
if (! Str::isUuid($id)) {
|
||||||
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
||||||
}
|
}
|
||||||
$collection->push($id);
|
$collection->push($id);
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ use App\Enums\Role;
|
|||||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||||
|
|
||||||
@@ -23,7 +21,11 @@ class InvitationService
|
|||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
if (Member::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereRelation('user', 'email', '=', $email)
|
||||||
|
->where('role', '!=', Role::Placeholder->value)
|
||||||
|
->exists()) {
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,37 +48,4 @@ class InvitationService
|
|||||||
|
|
||||||
return $invitation;
|
return $invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Organization>
|
|
||||||
*/
|
|
||||||
public function processAcceptedInvitations(User $user): Collection
|
|
||||||
{
|
|
||||||
$organizations = new Collection;
|
|
||||||
|
|
||||||
$invitations = OrganizationInvitation::query()
|
|
||||||
->where('email', $user->email)
|
|
||||||
->whereNotNull('accepted_at')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($invitations as $invitation) {
|
|
||||||
$organization = $invitation->organization;
|
|
||||||
$role = Role::tryFrom($invitation->role);
|
|
||||||
if ($role === null) {
|
|
||||||
Log::error('Invalid role in invitation', [
|
|
||||||
'invitation' => $invitation->getKey(),
|
|
||||||
'role' => $invitation->role,
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
app(MemberService::class)->addMember($user, $organization, $role);
|
|
||||||
|
|
||||||
$invitation->delete();
|
|
||||||
|
|
||||||
$organizations->push($organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $organizations;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,30 +96,6 @@ class LocalizationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a duration for reporting contexts (PDF reports, places that display duration
|
|
||||||
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
|
|
||||||
* so totals stay narrow and reconcile with cost, which is always computed to the second.
|
|
||||||
*/
|
|
||||||
public function formatIntervalForReporting(CarbonInterval $interval): string
|
|
||||||
{
|
|
||||||
$promoted = [
|
|
||||||
IntervalFormat::HoursMinutes,
|
|
||||||
IntervalFormat::HoursMinutesColonSeparated,
|
|
||||||
];
|
|
||||||
if (! in_array($this->intervalFormat, $promoted, true)) {
|
|
||||||
return $this->formatInterval($interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $this->intervalFormat;
|
|
||||||
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
|
|
||||||
try {
|
|
||||||
return $this->formatInterval($interval);
|
|
||||||
} finally {
|
|
||||||
$this->intervalFormat = $previous;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function formatCurrency(Money $money): string
|
public function formatCurrency(Money $money): string
|
||||||
{
|
{
|
||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\MemberAdded;
|
|
||||||
use App\Events\MemberAdding;
|
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
@@ -38,8 +36,7 @@ class MemberService
|
|||||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||||
{
|
{
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
MemberAdding::dispatch($user, $organization, $role);
|
AddingTeamMember::dispatch($organization, $user);
|
||||||
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -52,37 +49,14 @@ class MemberService
|
|||||||
$user->currentOrganization()->associate($organization);
|
$user->currentOrganization()->associate($organization);
|
||||||
$user->save();
|
$user->save();
|
||||||
});
|
});
|
||||||
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
|
||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
MemberAdded::dispatch($member, $organization, $user);
|
TeamMemberAdded::dispatch($organization, $user);
|
||||||
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $member;
|
return $member;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
|
||||||
{
|
|
||||||
$placeholders = Member::query()
|
|
||||||
->whereHas('user', function (Builder $query) use ($user): void {
|
|
||||||
/** @var Builder<User> $query */
|
|
||||||
$query->where('is_placeholder', '=', true)
|
|
||||||
->where('email', '=', $user->email);
|
|
||||||
})
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->with(['user'])
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($placeholders as $placeholder) {
|
|
||||||
/** @var Member $placeholder */
|
|
||||||
$placeholderUser = $placeholder->user;
|
|
||||||
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
|
||||||
$placeholder->delete();
|
|
||||||
$placeholderUser->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws CanNotRemoveOwnerFromOrganization
|
* @throws CanNotRemoveOwnerFromOrganization
|
||||||
* @throws EntityStillInUseApiException
|
* @throws EntityStillInUseApiException
|
||||||
@@ -222,7 +196,6 @@ class MemberService
|
|||||||
|
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
$placeholderUser->is_placeholder = true;
|
$placeholderUser->is_placeholder = true;
|
||||||
$placeholderUser->current_team_id = $member->organization_id;
|
|
||||||
$placeholderUser->save();
|
$placeholderUser->save();
|
||||||
|
|
||||||
$member->user()->associate($placeholderUser);
|
$member->user()->associate($placeholderUser);
|
||||||
@@ -235,13 +208,4 @@ class MemberService
|
|||||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
|
||||||
{
|
|
||||||
return Member::query()
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereRelation('user', 'email', '=', $email)
|
|
||||||
->where('role', '!=', Role::Placeholder->value)
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,238 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Laravel\Jetstream\Jetstream;
|
||||||
|
use Laravel\Jetstream\Role;
|
||||||
|
|
||||||
class PermissionStore
|
class PermissionStore
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @var array<string, array{name: string, permissions: array<string>, description: string}>
|
|
||||||
*/
|
|
||||||
private const array ROLE_DEFINITIONS = [
|
|
||||||
'owner' => [
|
|
||||||
'name' => 'Owner',
|
|
||||||
'permissions' => [
|
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'organizations:update',
|
|
||||||
'organizations:delete',
|
|
||||||
'import',
|
|
||||||
'export',
|
|
||||||
'invitations:view',
|
|
||||||
'invitations:create',
|
|
||||||
'invitations:resend',
|
|
||||||
'invitations:remove',
|
|
||||||
'members:view',
|
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:change-ownership',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:update',
|
|
||||||
'members:delete',
|
|
||||||
'billing',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
],
|
|
||||||
'description' => 'Owner users can perform any action. There is only one owner per organization.',
|
|
||||||
],
|
|
||||||
'admin' => [
|
|
||||||
'name' => 'Administrator',
|
|
||||||
'permissions' => [
|
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'organizations:update',
|
|
||||||
'import',
|
|
||||||
'export',
|
|
||||||
'invitations:view',
|
|
||||||
'invitations:create',
|
|
||||||
'invitations:resend',
|
|
||||||
'invitations:remove',
|
|
||||||
'members:view',
|
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:delete',
|
|
||||||
'members:update',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
],
|
|
||||||
'description' => 'Administrator users can perform any action, except accessing the billing dashboard.',
|
|
||||||
],
|
|
||||||
'manager' => [
|
|
||||||
'name' => 'Manager',
|
|
||||||
'permissions' => [
|
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'invitations:view',
|
|
||||||
'members:view',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
],
|
|
||||||
'description' => 'Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).',
|
|
||||||
],
|
|
||||||
'employee' => [
|
|
||||||
'name' => 'Employee',
|
|
||||||
'permissions' => [
|
|
||||||
'charts:view:own',
|
|
||||||
'projects:view',
|
|
||||||
'tags:view',
|
|
||||||
'tasks:view',
|
|
||||||
'clients:view',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'organizations:view',
|
|
||||||
],
|
|
||||||
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
|
|
||||||
],
|
|
||||||
'placeholder' => [
|
|
||||||
'name' => 'Placeholder',
|
|
||||||
'permissions' => [],
|
|
||||||
'description' => 'Placeholders are used for importing data. They cannot log in and have no permissions.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, array<string>>
|
|
||||||
*/
|
|
||||||
private static array $customRolePermissions = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, array<string>>
|
* @var array<string, array<string>>
|
||||||
*/
|
*/
|
||||||
@@ -246,37 +22,6 @@ class PermissionStore
|
|||||||
$this->permissionCache = [];
|
$this->permissionCache = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{name: string, permissions: array<string>, description: string}>
|
|
||||||
*/
|
|
||||||
public static function roleDefinitions(): array
|
|
||||||
{
|
|
||||||
return self::ROLE_DEFINITIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string> $permissions
|
|
||||||
*/
|
|
||||||
public static function registerCustomRole(string $role, array $permissions): void
|
|
||||||
{
|
|
||||||
self::$customRolePermissions[$role] = $permissions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resetCustomRoles(): void
|
|
||||||
{
|
|
||||||
self::$customRolePermissions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string>
|
|
||||||
*/
|
|
||||||
public static function permissionsForRole(string $role): array
|
|
||||||
{
|
|
||||||
return self::$customRolePermissions[$role]
|
|
||||||
?? self::ROLE_DEFINITIONS[$role]['permissions']
|
|
||||||
?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function has(Organization $organization, string $permission): bool
|
public function has(Organization $organization, string $permission): bool
|
||||||
{
|
{
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
@@ -323,11 +68,14 @@ class PermissionStore
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$permissions = self::permissionsForRole($role);
|
/** @var Role|null $roleObj */
|
||||||
|
$roleObj = Jetstream::findRole($role);
|
||||||
|
|
||||||
|
$permissions = $roleObj->permissions ?? [];
|
||||||
|
|
||||||
// If the organization allows employees to manage tasks and the user is an employee,
|
// If the organization allows employees to manage tasks and the user is an employee,
|
||||||
// add the task management permissions for accessible projects
|
// add the task management permissions for accessible projects
|
||||||
if ($role === Role::Employee->value && $organization->employees_can_manage_tasks) {
|
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||||
$permissions = array_merge($permissions, [
|
$permissions = array_merge($permissions, [
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ use Illuminate\Support\Facades\Log;
|
|||||||
|
|
||||||
class TimeEntryFilter
|
class TimeEntryFilter
|
||||||
{
|
{
|
||||||
public const string NONE_VALUE = 'none';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Builder<TimeEntry>
|
* @var Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
@@ -151,17 +149,7 @@ class TimeEntryFilter
|
|||||||
if ($clientIds === null) {
|
if ($clientIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
|
$this->builder->whereIn('client_id', $clientIds);
|
||||||
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
|
|
||||||
if (count($clientIds) > 0) {
|
|
||||||
$builder->whereIn('client_id', $clientIds);
|
|
||||||
}
|
|
||||||
if ($includeNone) {
|
|
||||||
$builder->orWhereNull('client_id');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -174,17 +162,7 @@ class TimeEntryFilter
|
|||||||
if ($projectIds === null) {
|
if ($projectIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
|
$this->builder->whereIn('project_id', $projectIds);
|
||||||
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
|
|
||||||
if (count($projectIds) > 0) {
|
|
||||||
$builder->whereIn('project_id', $projectIds);
|
|
||||||
}
|
|
||||||
if ($includeNone) {
|
|
||||||
$builder->orWhereNull('project_id');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -197,18 +175,10 @@ class TimeEntryFilter
|
|||||||
if ($tagIds === null) {
|
if ($tagIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
$this->builder->where(function (Builder $builder) use ($tagIds): void {
|
||||||
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$builder->orWhereJsonContains('tags', $tagId);
|
$builder->orWhereJsonContains('tags', $tagId);
|
||||||
}
|
}
|
||||||
if ($includeNone) {
|
|
||||||
$builder->orWhere(function (Builder $query): void {
|
|
||||||
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -222,17 +192,7 @@ class TimeEntryFilter
|
|||||||
if ($taskIds === null) {
|
if ($taskIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
|
$this->builder->whereIn('task_id', $taskIds);
|
||||||
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
|
||||||
|
|
||||||
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
|
|
||||||
if (count($taskIds) > 0) {
|
|
||||||
$builder->whereIn('task_id', $taskIds);
|
|
||||||
}
|
|
||||||
if ($includeNone) {
|
|
||||||
$builder->orWhereNull('task_id');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,12 @@ class TimeEntryService
|
|||||||
throw new LogicException('Rounding minutes must be greater than 0');
|
throw new LogicException('Rounding minutes must be greater than 0');
|
||||||
}
|
}
|
||||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||||
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
|
||||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
if ($roundingType === TimeEntryRoundingType::Down) {
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||||
// If end is already on a boundary, keep it; otherwise round up to next boundary
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||||
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
|
|
||||||
'THEN '.$end.' '.
|
|
||||||
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
|
|
||||||
'END';
|
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class UserService
|
|||||||
): User {
|
): User {
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
$user->email = strtolower($email);
|
$user->email = $email;
|
||||||
$user->password = Hash::make($password);
|
$user->password = Hash::make($password);
|
||||||
$user->timezone = $timezone;
|
$user->timezone = $timezone;
|
||||||
$user->week_start = $weekStart;
|
$user->week_start = $weekStart;
|
||||||
@@ -47,22 +47,19 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
|
$this->getOrganizationNameForUserName($user->name),
|
||||||
|
$user,
|
||||||
|
true,
|
||||||
|
$currency,
|
||||||
|
$numberFormat,
|
||||||
|
$currencyFormat,
|
||||||
|
$dateFormat,
|
||||||
|
$intervalFormat,
|
||||||
|
$timeFormat,
|
||||||
|
);
|
||||||
|
|
||||||
if ($organizations->isEmpty()) {
|
$user->ownedTeams()->save($organization);
|
||||||
$organization = app(OrganizationService::class)->createOrganization(
|
|
||||||
$this->getOrganizationNameForUserName($user->name),
|
|
||||||
$user,
|
|
||||||
true,
|
|
||||||
$currency,
|
|
||||||
$numberFormat,
|
|
||||||
$currencyFormat,
|
|
||||||
$dateFormat,
|
|
||||||
$intervalFormat,
|
|
||||||
$timeFormat,
|
|
||||||
);
|
|
||||||
$user->ownedTeams()->save($organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support;
|
|
||||||
|
|
||||||
use Symfony\Component\Mime\MimeTypes;
|
|
||||||
|
|
||||||
class Base64File
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array{data: string, mime_type: string}|null
|
|
||||||
*/
|
|
||||||
public static function decode(string $value): ?array
|
|
||||||
{
|
|
||||||
if (str_contains($value, ',')) {
|
|
||||||
[, $value] = explode(',', $value, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = preg_replace('/\s+/', '', $value);
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = base64_decode($value, true);
|
|
||||||
if ($decoded === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
|
|
||||||
if ($mimeType === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'data' => $decoded,
|
|
||||||
'mime_type' => $mimeType,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function extension(string $mimeType): ?string
|
|
||||||
{
|
|
||||||
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2066
composer.lock
generated
2066
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -25,24 +25,9 @@ class OrganizationInvitationFactory extends Factory
|
|||||||
'email' => $this->faker->unique()->safeEmail(),
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
'role' => Role::Employee->value,
|
'role' => Role::Employee->value,
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
'accepted_at' => null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function role(Role $role): self
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes) => [
|
|
||||||
'role' => $role->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function accepted(): self
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'accepted_at' => $this->faker->dateTime(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forOrganization(Organization $organization): self
|
public function forOrganization(Organization $organization): self
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('organization_invitations', function (Blueprint $table): void {
|
|
||||||
$table->timestamp('accepted_at')->nullable()->after('email');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('organization_invitations', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('accepted_at');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table): void {
|
|
||||||
$table->string('pending_email')->nullable()->after('email');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('pending_email');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*
|
|
||||||
* @throws RuntimeException
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
$duplicateEmails = DB::table('users')
|
|
||||||
->selectRaw('LOWER(email) as normalized_email')
|
|
||||||
->selectRaw('COUNT(*) as user_count')
|
|
||||||
->selectRaw("STRING_AGG(id::text || ' <' || email || '>', ', ' ORDER BY email) as users")
|
|
||||||
->where('is_placeholder', false)
|
|
||||||
->groupByRaw('LOWER(email)')
|
|
||||||
->havingRaw('COUNT(*) > 1')
|
|
||||||
->orderBy('normalized_email')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($duplicateEmails->isNotEmpty()) {
|
|
||||||
$duplicateEmailMessage = $duplicateEmails
|
|
||||||
->take(20)
|
|
||||||
->map(fn (\stdClass $duplicateEmail): string => sprintf(
|
|
||||||
'%s (%d users: %s)',
|
|
||||||
$duplicateEmail->normalized_email,
|
|
||||||
$duplicateEmail->user_count,
|
|
||||||
$duplicateEmail->users,
|
|
||||||
))
|
|
||||||
->implode('; ');
|
|
||||||
|
|
||||||
$remainingDuplicateCount = $duplicateEmails->count() - 20;
|
|
||||||
$remainingDuplicateMessage = $remainingDuplicateCount > 0
|
|
||||||
? sprintf('; and %d more duplicate normalized emails', $remainingDuplicateCount)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
throw new RuntimeException(
|
|
||||||
'Cannot lowercase users.email because doing so would create duplicate non-placeholder user emails and violate the unique index on users.email for non-placeholder users. Resolve these case-insensitive duplicates first: '.
|
|
||||||
$duplicateEmailMessage.
|
|
||||||
$remainingDuplicateMessage
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::table('users')
|
|
||||||
->whereRaw('email <> LOWER(email)')
|
|
||||||
->update([
|
|
||||||
'email' => DB::raw('LOWER(email)'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -107,7 +107,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.58.1-jammy
|
image: mcr.microsoft.com/playwright:v1.51.1-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
189
e2e/auth.spec.ts
189
e2e/auth.spec.ts
@@ -1,6 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { getPasswordResetUrl } from './utils/mailpit';
|
|
||||||
|
|
||||||
async function registerNewUser(page, email, password) {
|
async function registerNewUser(page, email, password) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||||
@@ -36,198 +35,14 @@ test('can register and delete account', async ({ page }) => {
|
|||||||
await registerNewUser(page, email, password);
|
await registerNewUser(page, email, password);
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
await page.getByPlaceholder('Password').fill(password);
|
await page.getByPlaceholder('Password').fill(password);
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click();
|
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
await page.getByLabel('Email').fill(email);
|
await page.getByLabel('Email').fill(email);
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel('Password').fill(password);
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
await expect(page.getByRole('alert')).toContainText(
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
'These credentials do not match our records.'
|
'These credentials do not match our records.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows error for invalid email on forgot password', async ({ page }) => {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
|
||||||
|
|
||||||
// Request password reset with non-existent email
|
|
||||||
await page.getByLabel('Email').fill('nonexistent@example.com');
|
|
||||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
|
||||||
|
|
||||||
// Should show error message
|
|
||||||
await expect(page.getByText("We can't find a user with that email address.")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows browser validation for invalid email format on forgot password', async ({ page }) => {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
|
||||||
|
|
||||||
// Request password reset with invalid email format
|
|
||||||
const emailInput = page.getByLabel('Email');
|
|
||||||
await emailInput.fill('notanemail');
|
|
||||||
|
|
||||||
// Check for browser validation - the input should be invalid
|
|
||||||
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
|
||||||
expect(isInvalid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows browser validation for empty email on forgot password', async ({ page }) => {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
|
||||||
|
|
||||||
// The email input is required, so it should be invalid when empty
|
|
||||||
const emailInput = page.getByLabel('Email');
|
|
||||||
|
|
||||||
// Check for browser validation - the input should be invalid because it's required and empty
|
|
||||||
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing);
|
|
||||||
expect(isInvalid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('can reset password via email link', async ({ page, request }) => {
|
|
||||||
// First register a new user
|
|
||||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
|
||||||
const originalPassword = 'suchagreatpassword123';
|
|
||||||
const newPassword = 'mynewsecurepassword456';
|
|
||||||
await registerNewUser(page, email, originalPassword);
|
|
||||||
|
|
||||||
// Log out
|
|
||||||
await page.getByTestId('current_user_button').click();
|
|
||||||
await page.getByText('Log Out').click();
|
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
|
||||||
|
|
||||||
// Request password reset
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
|
||||||
await page.getByLabel('Email').fill(email);
|
|
||||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
|
||||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
|
||||||
|
|
||||||
// Get password reset URL from email
|
|
||||||
const resetUrl = await getPasswordResetUrl(request, email);
|
|
||||||
|
|
||||||
// Navigate to reset page
|
|
||||||
await page.goto(resetUrl);
|
|
||||||
|
|
||||||
// Fill in new password
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
|
||||||
await page.getByLabel('Confirm Password').fill(newPassword);
|
|
||||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
|
||||||
|
|
||||||
// Should redirect to login page after successful reset
|
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
|
||||||
|
|
||||||
// Try logging in with new password
|
|
||||||
await page.getByLabel('Email').fill(email);
|
|
||||||
await page.getByLabel('Password').fill(newPassword);
|
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
|
||||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows validation error for password mismatch on reset', async ({ page, request }) => {
|
|
||||||
// First register a new user
|
|
||||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
|
||||||
const originalPassword = 'suchagreatpassword123';
|
|
||||||
await registerNewUser(page, email, originalPassword);
|
|
||||||
|
|
||||||
// Log out
|
|
||||||
await page.getByTestId('current_user_button').click();
|
|
||||||
await page.getByText('Log Out').click();
|
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
|
||||||
|
|
||||||
// Request password reset
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
|
||||||
await page.getByLabel('Email').fill(email);
|
|
||||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
|
||||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
|
||||||
|
|
||||||
// Get password reset URL from email
|
|
||||||
const resetUrl = await getPasswordResetUrl(request, email);
|
|
||||||
|
|
||||||
// Navigate to reset page
|
|
||||||
await page.goto(resetUrl);
|
|
||||||
|
|
||||||
// Fill in mismatched passwords
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill('newpassword123');
|
|
||||||
await page.getByLabel('Confirm Password').fill('differentpassword456');
|
|
||||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
|
||||||
|
|
||||||
// Should show validation error
|
|
||||||
await expect(page.getByText('The password field confirmation does not match.')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows validation error for short password on reset', async ({ page, request }) => {
|
|
||||||
// First register a new user
|
|
||||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
|
||||||
const originalPassword = 'suchagreatpassword123';
|
|
||||||
await registerNewUser(page, email, originalPassword);
|
|
||||||
|
|
||||||
// Log out
|
|
||||||
await page.getByTestId('current_user_button').click();
|
|
||||||
await page.getByText('Log Out').click();
|
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
|
||||||
|
|
||||||
// Request password reset
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
|
||||||
await page.getByLabel('Email').fill(email);
|
|
||||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
|
||||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
|
||||||
|
|
||||||
// Get password reset URL from email
|
|
||||||
const resetUrl = await getPasswordResetUrl(request, email);
|
|
||||||
|
|
||||||
// Navigate to reset page
|
|
||||||
await page.goto(resetUrl);
|
|
||||||
|
|
||||||
// Fill in short password
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill('short');
|
|
||||||
await page.getByLabel('Confirm Password').fill('short');
|
|
||||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
|
||||||
|
|
||||||
// Should show validation error about minimum length
|
|
||||||
await expect(page.getByText('must be at least')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows error for invalid login credentials', async ({ page }) => {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
|
||||||
await page.getByLabel('Email').fill('nonexistent@example.com');
|
|
||||||
await page.getByLabel('Password').fill('wrongpassword123');
|
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('These credentials do not match our records.')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows error when registering with existing email', async ({ page }) => {
|
|
||||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
|
||||||
const password = 'suchagreatpassword123';
|
|
||||||
|
|
||||||
// Register first user
|
|
||||||
await registerNewUser(page, email, password);
|
|
||||||
|
|
||||||
// Log out
|
|
||||||
await page.getByTestId('current_user_button').click();
|
|
||||||
await page.getByText('Log Out').click();
|
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
|
||||||
|
|
||||||
// Try to register with the same email
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
|
||||||
await page.getByLabel('Name').fill('Another User');
|
|
||||||
await page.getByLabel('Email').fill(email);
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
|
||||||
await page.getByLabel('Confirm Password').fill(password);
|
|
||||||
await page.getByLabel('I agree to the Terms of').click();
|
|
||||||
await page.getByRole('button', { name: 'Register' }).click();
|
|
||||||
|
|
||||||
// Should show error about email already taken
|
|
||||||
await expect(page.getByText('The resource already exists.')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows validation error for weak password on registration', async ({ page }) => {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
|
||||||
await page.getByLabel('Name').fill('Weak Password User');
|
|
||||||
await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`);
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill('short');
|
|
||||||
await page.getByLabel('Confirm Password').fill('short');
|
|
||||||
await page.getByLabel('I agree to the Terms of').click();
|
|
||||||
await page.getByRole('button', { name: 'Register' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('must be at least')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,689 +0,0 @@
|
|||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect } from '@playwright/test';
|
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
|
||||||
import { test } from '../playwright/fixtures';
|
|
||||||
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
|
|
||||||
|
|
||||||
async function goToCalendar(page: Page) {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
|
||||||
await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openSettingsPopover(page: Page) {
|
|
||||||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
|
||||||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearCalendarSettings(page: Page) {
|
|
||||||
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarTitle(page: Page) {
|
|
||||||
return page.getByTestId('calendar-title');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrollCalendarToTime(page: Page, time: string) {
|
|
||||||
await page.evaluate((t) => {
|
|
||||||
const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`);
|
|
||||||
if (slot) slot.scrollIntoView({ block: 'start' });
|
|
||||||
}, time);
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSlotHeight(page: Page): Promise<number> {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane'));
|
|
||||||
for (let i = 0; i < slots.length; i++) {
|
|
||||||
const h = slots[i].getBoundingClientRect().height;
|
|
||||||
if (h > 0) return h;
|
|
||||||
}
|
|
||||||
return 20;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Calendar Settings', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await clearCalendarSettings(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings popover shows all fields with correct defaults', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
|
|
||||||
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
|
|
||||||
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
|
|
||||||
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('snap interval can be changed and persists across reload', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Change snap interval to 30 min
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
|
|
||||||
// Close the popover by pressing Escape
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Verify localStorage was updated
|
|
||||||
const stored = await page.evaluate(() =>
|
|
||||||
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
|
|
||||||
);
|
|
||||||
expect(stored.snapMinutes).toBe(30);
|
|
||||||
|
|
||||||
// Reload and verify persistence
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('start time change is applied to calendar and rejects invalid values', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Verify 7 AM slot exists with default start (00:00)
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
|
|
||||||
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Set end time to 6 PM first
|
|
||||||
await page.getByLabel('End Time').click();
|
|
||||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
|
||||||
|
|
||||||
// Change start time to 8 AM (valid)
|
|
||||||
await page.getByLabel('Start Time').click();
|
|
||||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
|
||||||
|
|
||||||
// Try to set start time to 6 PM (invalid: equals end time) — should be rejected
|
|
||||||
await page.getByLabel('Start Time').click();
|
|
||||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
|
||||||
|
|
||||||
// Should be rejected — start time stays at 8 AM
|
|
||||||
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
|
|
||||||
|
|
||||||
// Close the popover
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Calendar should no longer show hours before 8 AM
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('end time change is applied to calendar and rejects invalid values', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Verify 19:00 slot exists with default end (24:00)
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
|
|
||||||
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Set start time to 8 AM first
|
|
||||||
await page.getByLabel('Start Time').click();
|
|
||||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
|
||||||
|
|
||||||
// Change end time to 6 PM (valid)
|
|
||||||
await page.getByLabel('End Time').click();
|
|
||||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
|
||||||
|
|
||||||
// Try to set end time to 8 AM (invalid: equals start time) — should be rejected
|
|
||||||
await page.getByLabel('End Time').click();
|
|
||||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
|
||||||
|
|
||||||
// Should be rejected — end time stays at 6 PM
|
|
||||||
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
|
|
||||||
|
|
||||||
// Close the popover
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Calendar should no longer show hours at or after 6 PM
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('grid scale affects number of calendar slots', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Count slots with default 15-min scale
|
|
||||||
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
|
|
||||||
|
|
||||||
// Change to 30 min scale (should halve the slots)
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Grid Scale').click();
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Wait for FullCalendar to re-render with new slot count
|
|
||||||
await expect(async () => {
|
|
||||||
const count = await page.locator('.fc-timegrid-slot').count();
|
|
||||||
expect(count).toBeLessThan(defaultSlotCount);
|
|
||||||
}).toPass({ timeout: 5000 });
|
|
||||||
|
|
||||||
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
|
||||||
|
|
||||||
// Navigate away and back to get a clean calendar mount
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Change to 5 min scale (many more slots)
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Grid Scale').click();
|
|
||||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Wait for FullCalendar to re-render with new slot count
|
|
||||||
await expect(async () => {
|
|
||||||
const count = await page.locator('.fc-timegrid-slot').count();
|
|
||||||
expect(count).toBeGreaterThan(largerSlotCount);
|
|
||||||
}).toPass({ timeout: 5000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all settings persist across navigation', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Change every setting
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
|
||||||
await page.getByLabel('Start Time').click();
|
|
||||||
await page.getByRole('option', { name: '6:00 AM' }).click();
|
|
||||||
await page.getByLabel('End Time').click();
|
|
||||||
await page.getByRole('option', { name: '10:00 PM' }).click();
|
|
||||||
await page.getByLabel('Grid Scale').click();
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
|
|
||||||
// Close the popover
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Navigate away and back
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Verify all settings persisted
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
|
|
||||||
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
|
|
||||||
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
|
|
||||||
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Calendar Toolbar', () => {
|
|
||||||
test('prev and next buttons navigate the calendar', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Use column headers to detect navigation (title only shows month which may not change)
|
|
||||||
const getHeaderTexts = async () => {
|
|
||||||
const headers = page.locator('.fc-col-header-cell');
|
|
||||||
return headers.allTextContents();
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialHeaders = await getHeaderTexts();
|
|
||||||
|
|
||||||
// Click next
|
|
||||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
const nextHeaders = await getHeaderTexts();
|
|
||||||
expect(nextHeaders).not.toEqual(initialHeaders);
|
|
||||||
|
|
||||||
// Click prev — should go back to original
|
|
||||||
await page.getByRole('button', { name: 'Previous', exact: true }).click();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
const backHeaders = await getHeaderTexts();
|
|
||||||
expect(backHeaders).toEqual(initialHeaders);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('today button returns to current week', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Use column headers to detect navigation (title only shows month which may not change)
|
|
||||||
const getHeaderTexts = async () => {
|
|
||||||
const headers = page.locator('.fc-col-header-cell');
|
|
||||||
return headers.allTextContents();
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialHeaders = await getHeaderTexts();
|
|
||||||
|
|
||||||
// Navigate away
|
|
||||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
|
||||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
|
||||||
|
|
||||||
const awayHeaders = await getHeaderTexts();
|
|
||||||
expect(awayHeaders).not.toEqual(initialHeaders);
|
|
||||||
|
|
||||||
// Click today
|
|
||||||
await page.getByRole('button', { name: 'today', exact: true }).click();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
const todayHeaders = await getHeaderTexts();
|
|
||||||
expect(todayHeaders).toEqual(initialHeaders);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('view switcher toggles between week and day views', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Default should be week view — verify multiple day columns exist
|
|
||||||
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
|
|
||||||
|
|
||||||
// Switch to day view
|
|
||||||
await page.getByRole('tab', { name: 'day', exact: true }).click();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
// Day view should show exactly 1 day column
|
|
||||||
await expect(page.locator('.fc-col-header-cell')).toHaveCount(1);
|
|
||||||
|
|
||||||
// Switch back to week view
|
|
||||||
await page.getByRole('tab', { name: 'week', exact: true }).click();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
// Week view should show multiple day columns again
|
|
||||||
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Visual Snapping', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await clearCalendarSettings(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Set snap interval to 1 min
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '1 min' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Create a 1h time entry
|
|
||||||
await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Scroll the calendar so the 14:00 target area is visible
|
|
||||||
await scrollCalendarToTime(page, '13:00:00');
|
|
||||||
|
|
||||||
const event = page.locator('.fc-event').first();
|
|
||||||
await expect(event).toBeVisible();
|
|
||||||
|
|
||||||
// Get target slot at a non-15-min boundary time
|
|
||||||
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
|
||||||
const targetBox = await targetSlot.boundingBox();
|
|
||||||
expect(targetBox).not.toBeNull();
|
|
||||||
|
|
||||||
// Drag event to a position offset from the 15-min boundary
|
|
||||||
const putResponsePromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
|
||||||
);
|
|
||||||
|
|
||||||
await event.hover();
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const putResponse = await putResponsePromise;
|
|
||||||
expect(putResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
const body = await putResponse.json();
|
|
||||||
const startDate = new Date(body.data.start);
|
|
||||||
const minutes = startDate.getMinutes();
|
|
||||||
|
|
||||||
// With 1-min snap, any minute value is valid (0-59)
|
|
||||||
expect(minutes).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(minutes).toBeLessThanOrEqual(59);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Set snap interval to 60 min
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '1 hour' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Create a 1h time entry
|
|
||||||
await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Scroll the calendar so the 14:00 target area is visible
|
|
||||||
await scrollCalendarToTime(page, '13:00:00');
|
|
||||||
|
|
||||||
const event = page.locator('.fc-event').first();
|
|
||||||
await expect(event).toBeVisible();
|
|
||||||
|
|
||||||
// Get target slot
|
|
||||||
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
|
||||||
const targetBox = await targetSlot.boundingBox();
|
|
||||||
expect(targetBox).not.toBeNull();
|
|
||||||
|
|
||||||
// Drag event
|
|
||||||
const putResponsePromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
|
||||||
);
|
|
||||||
|
|
||||||
await event.hover();
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const putResponse = await putResponsePromise;
|
|
||||||
expect(putResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
const body = await putResponse.json();
|
|
||||||
const startDate = new Date(body.data.start);
|
|
||||||
const minutes = startDate.getMinutes();
|
|
||||||
|
|
||||||
// With 60-min snap, minutes should be 0 (on the hour)
|
|
||||||
expect(minutes).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => {
|
|
||||||
// Create a 1h time entry
|
|
||||||
await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Set snap to 15 min
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '15 min' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Scroll the calendar so the 14:00 target area is visible
|
|
||||||
await scrollCalendarToTime(page, '13:00:00');
|
|
||||||
|
|
||||||
const event = page.locator('.fc-event').first();
|
|
||||||
await expect(event).toBeVisible();
|
|
||||||
|
|
||||||
// Drag event to 14:00 area
|
|
||||||
const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
|
||||||
const targetBox14 = await targetSlot14.boundingBox();
|
|
||||||
expect(targetBox14).not.toBeNull();
|
|
||||||
|
|
||||||
const putResponsePromise1 = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
|
||||||
);
|
|
||||||
|
|
||||||
await event.hover();
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, {
|
|
||||||
steps: 10,
|
|
||||||
});
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const putResponse1 = await putResponsePromise1;
|
|
||||||
expect(putResponse1.status()).toBe(200);
|
|
||||||
|
|
||||||
const body1 = await putResponse1.json();
|
|
||||||
const startDate1 = new Date(body1.data.start);
|
|
||||||
expect(startDate1.getMinutes() % 15).toBe(0);
|
|
||||||
|
|
||||||
// Wait for query re-fetch/re-renders to fully settle after drag
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
// Change snap to 30 min
|
|
||||||
// Use Escape first to ensure no stale popover is open, then re-open
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
await page.getByLabel('Snap Interval').click({ force: true });
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Scroll the calendar so the 10:00 target area is visible
|
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
|
||||||
|
|
||||||
// Drag event to 10:00 area
|
|
||||||
const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
|
|
||||||
const targetBox10 = await targetSlot10.boundingBox();
|
|
||||||
expect(targetBox10).not.toBeNull();
|
|
||||||
|
|
||||||
const putResponsePromise2 = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
|
||||||
);
|
|
||||||
|
|
||||||
await event.hover();
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, {
|
|
||||||
steps: 10,
|
|
||||||
});
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const putResponse2 = await putResponsePromise2;
|
|
||||||
expect(putResponse2.status()).toBe(200);
|
|
||||||
|
|
||||||
const body2 = await putResponse2.json();
|
|
||||||
const startDate2 = new Date(body2.data.start);
|
|
||||||
expect(startDate2.getMinutes() % 30).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Set grid scale to 30 min, snap to 5 min
|
|
||||||
await page.getByLabel('Grid Scale').click();
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Wait for re-render with 30-min grid
|
|
||||||
await expect(async () => {
|
|
||||||
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
|
|
||||||
// 24 hours * 2 slots/hour = 48 slots for 30-min grid
|
|
||||||
expect(slotCount).toBeLessThanOrEqual(48);
|
|
||||||
}).toPass({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Verify grid is 30-min (fewer slots than default 15-min)
|
|
||||||
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
|
|
||||||
// Default 15-min grid has 96 slots; 30-min grid should have 48
|
|
||||||
expect(slotCount).toBeLessThanOrEqual(48);
|
|
||||||
|
|
||||||
// Create a 1h time entry and go to calendar
|
|
||||||
await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Re-apply settings since goToCalendar navigates
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Grid Scale').click();
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Scroll so both the event (9:00) and target (14:00) are in viewport
|
|
||||||
await scrollCalendarToTime(page, '08:00:00');
|
|
||||||
|
|
||||||
const event = page.locator('.fc-event').first();
|
|
||||||
await expect(event).toBeVisible();
|
|
||||||
|
|
||||||
// Capture target coordinates after scroll is settled
|
|
||||||
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
|
||||||
const targetBox = await targetSlot.boundingBox();
|
|
||||||
expect(targetBox).not.toBeNull();
|
|
||||||
|
|
||||||
const putResponsePromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
|
||||||
);
|
|
||||||
|
|
||||||
await event.hover();
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const putResponse = await putResponsePromise;
|
|
||||||
expect(putResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
const body = await putResponse.json();
|
|
||||||
const startDate = new Date(body.data.start);
|
|
||||||
// Snap is 5 min, so minutes should be divisible by 5
|
|
||||||
expect(startDate.getMinutes() % 5).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Calendar Settings Effects', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await clearCalendarSettings(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('start/end time hides slots outside visible range', async ({ page, ctx }) => {
|
|
||||||
// Create a time entry at 6 AM today
|
|
||||||
const now = new Date();
|
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0);
|
|
||||||
const end = new Date(start.getTime() + 3600 * 1000); // 7 AM
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
|
||||||
description: 'Early morning entry',
|
|
||||||
start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
||||||
end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Verify 6 AM slot is visible with default settings
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0);
|
|
||||||
|
|
||||||
// Set start time to 8 AM
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Start Time').click();
|
|
||||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// 6 AM slot should be hidden
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0);
|
|
||||||
|
|
||||||
// 8 AM slot should be visible
|
|
||||||
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('grid scale affects event visual height proportionally', async ({ page, ctx }) => {
|
|
||||||
// Create a 1h time entry
|
|
||||||
await createBareTimeEntryViaApi(ctx, 'Height test', '1h');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
const event = page.locator('.fc-event').first();
|
|
||||||
await expect(event).toBeVisible();
|
|
||||||
await event.scrollIntoViewIfNeeded();
|
|
||||||
|
|
||||||
// Get event height with default 15-min grid scale
|
|
||||||
const box15 = await event.boundingBox();
|
|
||||||
expect(box15).not.toBeNull();
|
|
||||||
const height15 = box15!.height;
|
|
||||||
|
|
||||||
// Change grid scale to 60 min
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Grid Scale').click();
|
|
||||||
await page.getByRole('option', { name: '1 hour' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Wait for re-render and scroll event into view
|
|
||||||
await event.scrollIntoViewIfNeeded();
|
|
||||||
await expect(async () => {
|
|
||||||
const box = await event.boundingBox();
|
|
||||||
expect(box).not.toBeNull();
|
|
||||||
expect(box!.height).not.toBe(height15);
|
|
||||||
}).toPass({ timeout: 5000 });
|
|
||||||
|
|
||||||
const box60 = await event.boundingBox();
|
|
||||||
expect(box60).not.toBeNull();
|
|
||||||
const height60 = box60!.height;
|
|
||||||
|
|
||||||
// Event should appear smaller with larger grid scale
|
|
||||||
expect(height15).toBeGreaterThan(height60);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('snap interval affects drag granularity', async ({ page, ctx }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
|
|
||||||
// Set snap to 30 min
|
|
||||||
await page.getByLabel('Snap Interval').click();
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Create a 1h time entry
|
|
||||||
await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h');
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Scroll the calendar so the 14:00 target area is visible
|
|
||||||
await scrollCalendarToTime(page, '13:00:00');
|
|
||||||
|
|
||||||
const event = page.locator('.fc-event').first();
|
|
||||||
await expect(event).toBeVisible();
|
|
||||||
|
|
||||||
// Get target slot
|
|
||||||
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
|
||||||
const targetBox = await targetSlot.boundingBox();
|
|
||||||
expect(targetBox).not.toBeNull();
|
|
||||||
|
|
||||||
// Drag event
|
|
||||||
const putResponsePromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
|
||||||
);
|
|
||||||
|
|
||||||
await event.hover();
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const putResponse = await putResponsePromise;
|
|
||||||
expect(putResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
const body = await putResponse.json();
|
|
||||||
const startDate = new Date(body.data.start);
|
|
||||||
const minutes = startDate.getMinutes();
|
|
||||||
|
|
||||||
// With 30-min snap, minutes should be 0 or 30
|
|
||||||
expect(minutes % 30).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings apply immediately without page reload', async ({ page }) => {
|
|
||||||
await goToCalendar(page);
|
|
||||||
|
|
||||||
// Count slots with default grid scale (15 min)
|
|
||||||
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
|
|
||||||
|
|
||||||
// Change grid scale to 30 min
|
|
||||||
await openSettingsPopover(page);
|
|
||||||
await page.getByLabel('Grid Scale').click();
|
|
||||||
await page.getByRole('option', { name: '30 min' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Verify slot count changed without navigation
|
|
||||||
await expect(async () => {
|
|
||||||
const count = await page.locator('.fc-timegrid-slot').count();
|
|
||||||
expect(count).toBeLessThan(defaultSlotCount);
|
|
||||||
}).toPass({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Wait for FullCalendar to fully stabilize after re-render
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
// Change start time to 8 AM
|
|
||||||
// FullCalendar re-render from grid scale change can make popover elements unstable.
|
|
||||||
// Retry the open+click sequence if it fails.
|
|
||||||
await expect(async () => {
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
|
||||||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
|
||||||
const startTimeBtn = page.getByLabel('Start Time');
|
|
||||||
await expect(startTimeBtn).toBeVisible();
|
|
||||||
await startTimeBtn.click({ timeout: 3000 });
|
|
||||||
}).toPass({ timeout: 10000 });
|
|
||||||
|
|
||||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Verify 7 AM slot is hidden without reload
|
|
||||||
await expect(async () => {
|
|
||||||
const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count();
|
|
||||||
expect(count).toBe(0);
|
|
||||||
}).toPass({ timeout: 5000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
2882
e2e/calendar.spec.ts
2882
e2e/calendar.spec.ts
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,15 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect, Page } from '@playwright/test';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
import {
|
|
||||||
createClientViaApi,
|
|
||||||
createProjectMemberViaApi,
|
|
||||||
createProjectViaApi,
|
|
||||||
createPublicProjectViaApi,
|
|
||||||
} from './utils/api';
|
|
||||||
import { getTableRowNames } from './utils/table';
|
|
||||||
|
|
||||||
async function goToClientsOverview(page: Page) {
|
async function goToProjectsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new client via modal
|
// Create new project via modal
|
||||||
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
||||||
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await goToClientsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||||
await page.getByPlaceholder('Client Name').fill(newClientName);
|
await page.getByPlaceholder('Client Name').fill(newClientName);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -34,7 +26,7 @@ test('test that creating and deleting a new client via the modal works', async (
|
|||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
||||||
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
||||||
await moreButton.click();
|
moreButton.click();
|
||||||
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -49,11 +41,13 @@ test('test that creating and deleting a new client via the modal works', async (
|
|||||||
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
|
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that archiving and unarchiving clients works', async ({ page, ctx }) => {
|
test('test that archiving and unarchiving clients works', async ({ page }) => {
|
||||||
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await createClientViaApi(ctx, { name: newClientName });
|
await goToProjectsOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||||
|
await page.getByLabel('Client Name').fill(newClientName);
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||||
await expect(page.getByText(newClientName)).toBeVisible();
|
await expect(page.getByText(newClientName)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
@@ -77,300 +71,4 @@ test('test that archiving and unarchiving clients works', async ({ page, ctx })
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that editing a client name works', async ({ page, ctx }) => {
|
// TODO: Add Name Update Test
|
||||||
const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
await createClientViaApi(ctx, { name: originalName });
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await expect(page.getByText(originalName)).toBeVisible();
|
|
||||||
|
|
||||||
// Open edit modal via actions menu
|
|
||||||
const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']");
|
|
||||||
await moreButton.click();
|
|
||||||
await page.getByTestId('client_edit').click();
|
|
||||||
|
|
||||||
// Update the client name
|
|
||||||
await page.getByPlaceholder('Client Name').fill(updatedName);
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Update Client' }).click(),
|
|
||||||
page.waitForResponse(
|
|
||||||
async (response) =>
|
|
||||||
response.url().includes('/clients') &&
|
|
||||||
response.request().method() === 'PUT' &&
|
|
||||||
response.status() === 200
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Verify updated name is shown and old name is gone
|
|
||||||
await expect(page.getByTestId('client_table')).toContainText(updatedName);
|
|
||||||
await expect(page.getByTestId('client_table')).not.toContainText(originalName);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that deleting a client via actions menu works', async ({ page, ctx }) => {
|
|
||||||
const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
|
|
||||||
await createClientViaApi(ctx, { name: clientName });
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await expect(page.getByTestId('client_table')).toContainText(clientName);
|
|
||||||
|
|
||||||
const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']");
|
|
||||||
await moreButton.click();
|
|
||||||
const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']");
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
deleteButton.click(),
|
|
||||||
page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.url().includes('/clients') &&
|
|
||||||
response.request().method() === 'DELETE' &&
|
|
||||||
response.status() === 204
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Context Menu Tests
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
test('test that client context menu edit updates the client', async ({ page, ctx }) => {
|
|
||||||
const clientName = 'CtxEditClient ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
const updatedName = 'CtxUpdatedClient ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
await createClientViaApi(ctx, { name: clientName });
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
|
|
||||||
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
|
||||||
await expect(row).toBeVisible();
|
|
||||||
await row.click({ button: 'right' });
|
|
||||||
await expect(page.getByRole('menu')).toBeVisible();
|
|
||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByPlaceholder('Client Name').fill(updatedName);
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Update Client' }).click(),
|
|
||||||
page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.url().includes('/clients') &&
|
|
||||||
response.request().method() === 'PUT' &&
|
|
||||||
response.status() === 200
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toContainText(updatedName);
|
|
||||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that client context menu archive archives the client', async ({ page, ctx }) => {
|
|
||||||
const clientName = 'CtxArchiveClient ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
await createClientViaApi(ctx, { name: clientName });
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
|
|
||||||
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
|
||||||
await expect(row).toBeVisible();
|
|
||||||
await row.click({ button: 'right' });
|
|
||||||
await expect(page.getByRole('menu')).toBeVisible();
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.url().includes('/clients') &&
|
|
||||||
response.request().method() === 'PUT' &&
|
|
||||||
response.status() === 200
|
|
||||||
),
|
|
||||||
page.getByRole('menuitem', { name: 'Archive' }).click(),
|
|
||||||
]);
|
|
||||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that client context menu delete deletes the client', async ({ page, ctx }) => {
|
|
||||||
const clientName = 'CtxDeleteClient ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
await createClientViaApi(ctx, { name: clientName });
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
|
|
||||||
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
|
||||||
await expect(row).toBeVisible();
|
|
||||||
await row.click({ button: 'right' });
|
|
||||||
await expect(page.getByRole('menu')).toBeVisible();
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.url().includes('/clients') &&
|
|
||||||
response.request().method() === 'DELETE' &&
|
|
||||||
response.status() === 204
|
|
||||||
),
|
|
||||||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
|
||||||
]);
|
|
||||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Sorting Tests
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
async function clearClientTableState(page: Page) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.removeItem('client-table-state');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
|
|
||||||
await createClientViaApi(ctx, { name: 'AAA SortClient' });
|
|
||||||
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const table = page.getByTestId('client_table');
|
|
||||||
await expect(table).toBeVisible();
|
|
||||||
|
|
||||||
// -- Name sorting (default is name asc) --
|
|
||||||
let names = await getTableRowNames(table);
|
|
||||||
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
|
|
||||||
|
|
||||||
const nameHeader = table.getByText('Name').first();
|
|
||||||
await nameHeader.click(); // toggle to desc
|
|
||||||
names = await getTableRowNames(table);
|
|
||||||
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
|
|
||||||
|
|
||||||
// -- Status sorting --
|
|
||||||
const statusHeader = table.getByText('Status').first();
|
|
||||||
await statusHeader.click(); // asc
|
|
||||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
|
||||||
await statusHeader.click(); // desc
|
|
||||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
|
||||||
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
|
||||||
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
|
||||||
|
|
||||||
// Create projects for the first client
|
|
||||||
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
|
||||||
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const table = page.getByTestId('client_table');
|
|
||||||
await expect(table).toBeVisible();
|
|
||||||
|
|
||||||
// Click Projects header - first click should sort desc (most projects first)
|
|
||||||
const projectsHeader = table.getByText('Projects').first();
|
|
||||||
await projectsHeader.click();
|
|
||||||
await expect(projectsHeader.locator('svg')).toBeVisible();
|
|
||||||
let names = await getTableRowNames(table);
|
|
||||||
expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client'));
|
|
||||||
|
|
||||||
// Second click toggles to asc (least projects first)
|
|
||||||
await projectsHeader.click();
|
|
||||||
names = await getTableRowNames(table);
|
|
||||||
expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that client sort state persists after page reload', async ({ page }) => {
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const table = page.getByTestId('client_table');
|
|
||||||
await expect(table).toBeVisible();
|
|
||||||
|
|
||||||
const nameHeader = table.getByText('Name').first();
|
|
||||||
await nameHeader.click(); // toggle to desc
|
|
||||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId('client_table').getByText('Name').first().locator('svg')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Employee Permission Tests
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
test.describe('Employee Clients Restrictions', () => {
|
|
||||||
test('employee can view clients but cannot create', async ({ ctx, employee }) => {
|
|
||||||
// Create a client with a public project so the employee can see the client
|
|
||||||
const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000);
|
|
||||||
const client = await createClientViaApi(ctx, { name: clientName });
|
|
||||||
await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id });
|
|
||||||
|
|
||||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
|
||||||
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Employee can see the client
|
|
||||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Employee cannot see Create Client button
|
|
||||||
await expect(
|
|
||||||
employee.page.getByRole('button', { name: 'Create Client' })
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('employee cannot see edit/delete/archive actions on clients', async ({
|
|
||||||
ctx,
|
|
||||||
employee,
|
|
||||||
}) => {
|
|
||||||
const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000);
|
|
||||||
const client = await createClientViaApi(ctx, { name: clientName });
|
|
||||||
await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id });
|
|
||||||
|
|
||||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
|
||||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Click the actions dropdown trigger to open the menu
|
|
||||||
const actionsButton = employee.page.locator(
|
|
||||||
`[aria-label='Actions for Client ${clientName}']`
|
|
||||||
);
|
|
||||||
await actionsButton.click();
|
|
||||||
|
|
||||||
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
|
|
||||||
await expect(
|
|
||||||
employee.page.locator(`[aria-label='Edit Client ${clientName}']`)
|
|
||||||
).not.toBeVisible();
|
|
||||||
await expect(
|
|
||||||
employee.page.locator(`[aria-label='Archive Client ${clientName}']`)
|
|
||||||
).not.toBeVisible();
|
|
||||||
await expect(
|
|
||||||
employee.page.locator(`[aria-label='Delete Client ${clientName}']`)
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('employee can see client when they are a member of its private project', async ({
|
|
||||||
ctx,
|
|
||||||
employee,
|
|
||||||
}) => {
|
|
||||||
const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000);
|
|
||||||
const client = await createClientViaApi(ctx, { name: clientName });
|
|
||||||
|
|
||||||
// Create a private project under this client
|
|
||||||
const project = await createProjectViaApi(ctx, {
|
|
||||||
name: 'PrivateProj',
|
|
||||||
client_id: client.id,
|
|
||||||
is_public: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the employee as a project member
|
|
||||||
await createProjectMemberViaApi(ctx, project.id, {
|
|
||||||
member_id: employee.memberId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
|
||||||
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Employee can see the client because they are a member of its private project
|
|
||||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
import { expect, test } from '../playwright/fixtures';
|
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
const TIMER_BUTTON_SELECTOR = '[data-testid="dashboard_timer"] [data-testid="timer_button"]';
|
|
||||||
|
|
||||||
async function goToDashboard(page: Page) {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openCommandPalette(page: Page) {
|
|
||||||
await page.getByTestId('command_palette_button').click();
|
|
||||||
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeCommandPalette(page: Page) {
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchInCommandPalette(page: Page, query: string) {
|
|
||||||
await page.locator('[role="dialog"] input').fill(query);
|
|
||||||
// Wait for search debounce to settle (command palette uses a debounced search)
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectCommand(page: Page, name: string) {
|
|
||||||
const option = page.getByRole('option', { name, exact: true });
|
|
||||||
await option.scrollIntoViewIfNeeded();
|
|
||||||
await option.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertTimerIsRunning(page: Page) {
|
|
||||||
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
|
||||||
/bg-red-400\/80/,
|
|
||||||
{
|
|
||||||
timeout: 10000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertTimerIsStopped(page: Page) {
|
|
||||||
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
|
||||||
/bg-accent-300\/70/,
|
|
||||||
{
|
|
||||||
timeout: 10000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Command Palette', () => {
|
|
||||||
test.describe('Opening and Closing', () => {
|
|
||||||
test('opens via search button and closes with Escape', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await expect(
|
|
||||||
page.locator('[role="dialog"] input[placeholder*="command"]')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await closeCommandPalette(page);
|
|
||||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens with keyboard shortcut', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
// Click on body to ensure page has focus
|
|
||||||
await page.locator('body').click();
|
|
||||||
// Use ControlOrMeta which resolves to Ctrl on Linux/Windows and Meta on macOS
|
|
||||||
await page.keyboard.press('ControlOrMeta+k');
|
|
||||||
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clears search on close', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'dashboard');
|
|
||||||
await closeCommandPalette(page);
|
|
||||||
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await expect(page.locator('[role="dialog"] input')).toHaveValue('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Command Display', () => {
|
|
||||||
test('displays navigation and timer commands', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
// Navigation commands
|
|
||||||
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
|
|
||||||
|
|
||||||
// Timer commands
|
|
||||||
await expect(page.getByRole('option', { name: 'Start Timer' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Create Time Entry' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('displays create commands', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Create Client' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Create Tag' })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Navigation Commands', () => {
|
|
||||||
// Tests use element visibility assertions for consistency with codebase patterns
|
|
||||||
const navigationTests = [
|
|
||||||
['Go to Dashboard', 'dashboard_view', '/time'],
|
|
||||||
['Go to Time', 'time_view', '/dashboard'],
|
|
||||||
['Go to Calendar', 'calendar_view', '/dashboard'],
|
|
||||||
['Go to Projects', 'projects_view', '/dashboard'],
|
|
||||||
['Go to Clients', 'clients_view', '/dashboard'],
|
|
||||||
['Go to Members', 'members_view', '/dashboard'],
|
|
||||||
['Go to Tags', 'tags_view', '/dashboard'],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const [commandName, expectedTestId, startUrl] of navigationTests) {
|
|
||||||
test(`${commandName}`, async ({ page }) => {
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + startUrl);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, commandName.replace('Go to ', ''));
|
|
||||||
await selectCommand(page, commandName);
|
|
||||||
await expect(page.getByTestId(expectedTestId)).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('Go to Profile', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Profile');
|
|
||||||
await selectCommand(page, 'Go to Profile');
|
|
||||||
// Profile page doesn't have a testId, so check for a unique element
|
|
||||||
await expect(page.getByRole('heading', { name: 'Profile Information' })).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Go to Reporting Overview', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Reporting Overview');
|
|
||||||
await selectCommand(page, 'Go to Reporting Overview');
|
|
||||||
await expect(page.getByTestId('reporting_view')).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Go to Settings', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Settings');
|
|
||||||
await selectCommand(page, 'Go to Settings');
|
|
||||||
// Settings page uses team settings which has an h3 heading
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: 'Organization Name', level: 3 })
|
|
||||||
).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Search and Filtering', () => {
|
|
||||||
test('filters commands when searching', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
await searchInCommandPalette(page, 'dashboard');
|
|
||||||
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
|
||||||
|
|
||||||
await searchInCommandPalette(page, 'calendar');
|
|
||||||
await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('search is case insensitive', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
await searchInCommandPalette(page, 'DASHBOARD');
|
|
||||||
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial word search works', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
await searchInCommandPalette(page, 'proj');
|
|
||||||
await expect(page.getByRole('option', { name: 'Go to Projects' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard navigation and selection works', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('ArrowDown');
|
|
||||||
await page.keyboard.press('ArrowDown');
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
|
|
||||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Theme Commands', () => {
|
|
||||||
test('switches to dark theme', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Dark Theme');
|
|
||||||
await selectCommand(page, 'Switch to Dark Theme');
|
|
||||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('switches to light theme', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Light Theme');
|
|
||||||
await selectCommand(page, 'Switch to Light Theme');
|
|
||||||
await expect(page.locator('html')).toHaveClass(/light/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Timer Commands', () => {
|
|
||||||
test('starts and stops timer', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
|
|
||||||
// Start timer
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Start Timer');
|
|
||||||
await selectCommand(page, 'Start Timer');
|
|
||||||
await assertTimerIsRunning(page);
|
|
||||||
|
|
||||||
// Stop timer
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Stop Timer');
|
|
||||||
await selectCommand(page, 'Stop Timer');
|
|
||||||
await assertTimerIsStopped(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows active timer commands when running', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
|
|
||||||
// Start timer
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Start Timer');
|
|
||||||
await selectCommand(page, 'Start Timer');
|
|
||||||
await assertTimerIsRunning(page);
|
|
||||||
|
|
||||||
// Check active timer commands - search for them to ensure visibility
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Set Project');
|
|
||||||
await expect(page.getByRole('option', { name: 'Set Project' })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Create Commands', () => {
|
|
||||||
test('opens create time entry modal', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Create Time Entry');
|
|
||||||
await selectCommand(page, 'Create Time Entry');
|
|
||||||
await expect(
|
|
||||||
page.locator('[role="dialog"]').getByText('Create manual time entry')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens create project modal', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Create Project');
|
|
||||||
await selectCommand(page, 'Create Project');
|
|
||||||
await expect(
|
|
||||||
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Project' })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens create client modal', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Create Client');
|
|
||||||
await selectCommand(page, 'Create Client');
|
|
||||||
await expect(
|
|
||||||
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Client' })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens create tag modal', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Create Tag');
|
|
||||||
await selectCommand(page, 'Create Tag');
|
|
||||||
await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens invite member modal', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Invite Member');
|
|
||||||
await selectCommand(page, 'Invite Member');
|
|
||||||
// Modal has title with "Invite Member" text - use first() to get the title span
|
|
||||||
await expect(
|
|
||||||
page.locator('[role="dialog"]').getByText('Invite Member').first()
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Entity Search', () => {
|
|
||||||
test('searches for projects and navigates on selection', async ({ page }) => {
|
|
||||||
const projectName = 'CmdPalette' + Math.floor(Math.random() * 10000);
|
|
||||||
|
|
||||||
// Create project first
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
|
||||||
await page.getByPlaceholder('The next big thing').fill(projectName);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
|
||||||
// Wait for project to be created and page to update
|
|
||||||
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Search from the projects page where the query cache now has the new project
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, projectName);
|
|
||||||
|
|
||||||
// Wait for entity search to return results
|
|
||||||
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
|
||||||
await expect(projectOption).toBeVisible({
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Select the project from search results
|
|
||||||
await projectOption.click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Organization Switching', () => {
|
|
||||||
test('shows switch commands only when multiple organizations exist', async ({ page }) => {
|
|
||||||
await goToDashboard(page);
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
// With only one org, no switch commands should appear
|
|
||||||
await searchInCommandPalette(page, 'Switch to');
|
|
||||||
// Check that no organization switch commands appear (only theme switch commands)
|
|
||||||
const switchOptions = page.getByRole('option', { name: /^Switch to (?!.*Theme)/ });
|
|
||||||
await expect(switchOptions).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('switches organization via command palette', async ({ page }) => {
|
|
||||||
const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000);
|
|
||||||
|
|
||||||
// Create a new organization
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
|
|
||||||
await page.getByLabel('Organization Name').fill(newOrgName);
|
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
|
||||||
|
|
||||||
// Wait for navigation to new org's dashboard
|
|
||||||
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Use visible switcher (desktop sidebar has one, mobile header has another)
|
|
||||||
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');
|
|
||||||
|
|
||||||
// Verify we're in the new org by checking the switcher
|
|
||||||
await expect(orgSwitcher).toContainText(newOrgName);
|
|
||||||
|
|
||||||
// Get the original org name from switcher dropdown
|
|
||||||
await orgSwitcher.click();
|
|
||||||
await expect(page.getByText('Switch Organizations')).toBeVisible();
|
|
||||||
|
|
||||||
// Find the other organization button (has ArrowRightIcon, not CheckCircleIcon)
|
|
||||||
// The button contains an SVG and a div with the org name
|
|
||||||
const otherOrgItem = page.locator('form button').filter({ hasText: /.+/ }).first();
|
|
||||||
await expect(otherOrgItem).toBeVisible();
|
|
||||||
const originalOrgName = (await otherOrgItem.innerText()).trim();
|
|
||||||
await page.keyboard.press('Escape'); // Close dropdown
|
|
||||||
|
|
||||||
// Now use command palette to switch back to original org
|
|
||||||
await openCommandPalette(page);
|
|
||||||
await searchInCommandPalette(page, 'Switch to');
|
|
||||||
|
|
||||||
// Should see the switch command for the original org
|
|
||||||
const switchCommand = page.getByRole('option', {
|
|
||||||
name: new RegExp(`Switch to ${originalOrgName}`),
|
|
||||||
});
|
|
||||||
await expect(switchCommand).toBeVisible();
|
|
||||||
await switchCommand.click();
|
|
||||||
|
|
||||||
// Wait for organization switch to complete
|
|
||||||
await expect(orgSwitcher).toContainText(originalOrgName, {
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('organization switch commands appear in Organization group', async ({ page }) => {
|
|
||||||
const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000);
|
|
||||||
|
|
||||||
// Create a new organization to ensure we have multiple
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
|
|
||||||
await page.getByLabel('Organization Name').fill(newOrgName);
|
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
|
||||||
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Open command palette and check for Organization group heading
|
|
||||||
await openCommandPalette(page);
|
|
||||||
|
|
||||||
// The Organization group should be visible when there are switch commands
|
|
||||||
await expect(page.getByText('Organization', { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Employee Permission Tests
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
test.describe('Employee Command Palette Restrictions', () => {
|
|
||||||
test('employee command palette does not show restricted navigation commands', async ({
|
|
||||||
employee,
|
|
||||||
}) => {
|
|
||||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
||||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open command palette
|
|
||||||
await employee.page.getByTestId('command_palette_button').click();
|
|
||||||
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Available navigation commands
|
|
||||||
await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
|
||||||
await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
|
|
||||||
await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
|
|
||||||
|
|
||||||
// Restricted commands should NOT be visible
|
|
||||||
await expect(
|
|
||||||
employee.page.getByRole('option', { name: 'Go to Members' })
|
|
||||||
).not.toBeVisible();
|
|
||||||
await expect(
|
|
||||||
employee.page.getByRole('option', { name: 'Go to Settings' })
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('employee command palette does not show create commands for restricted entities', async ({
|
|
||||||
employee,
|
|
||||||
}) => {
|
|
||||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
||||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open command palette
|
|
||||||
await employee.page.getByTestId('command_palette_button').click();
|
|
||||||
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Search for "Create" to filter
|
|
||||||
await employee.page.locator('[role="dialog"] input').fill('Create');
|
|
||||||
await employee.page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Should NOT see create commands for restricted entities
|
|
||||||
await expect(
|
|
||||||
employee.page.getByRole('option', { name: 'Create Project' })
|
|
||||||
).not.toBeVisible();
|
|
||||||
await expect(
|
|
||||||
employee.page.getByRole('option', { name: 'Create Client' })
|
|
||||||
).not.toBeVisible();
|
|
||||||
await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible();
|
|
||||||
await expect(
|
|
||||||
employee.page.getByRole('option', { name: 'Invite Member' })
|
|
||||||
).not.toBeVisible();
|
|
||||||
|
|
||||||
// Should still see Create Time Entry (employees can create time entries)
|
|
||||||
await expect(
|
|
||||||
employee.page.getByRole('option', { name: 'Create Time Entry' })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user