mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 13:53:17 +01:00
Compare commits
77 Commits
feature/br
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7bc076cf | ||
|
|
f6b6b0a467 | ||
|
|
95645ddd91 | ||
|
|
1de985b577 | ||
|
|
d54296e66a | ||
|
|
95ddbf9ead | ||
|
|
70646a0dd4 | ||
|
|
5b12c09747 | ||
|
|
24023353f2 | ||
|
|
720d20c10e | ||
|
|
82ea9af8b5 | ||
|
|
169d522da0 | ||
|
|
45c7377802 | ||
|
|
8e57275cef | ||
|
|
efc6b55628 | ||
|
|
c12789376d | ||
|
|
4795812b60 | ||
|
|
0e00979ab8 | ||
|
|
f1426fcb5e | ||
|
|
23f512d4a4 | ||
|
|
637475e669 | ||
|
|
3ec2abb309 | ||
|
|
3e36b1cc01 | ||
|
|
7831bc697e | ||
|
|
602a8daa1f | ||
|
|
38b448a729 | ||
|
|
e6f071f87f | ||
|
|
693a1fa7e0 | ||
|
|
77f14b696e | ||
|
|
593372bae5 | ||
|
|
db9ca51fc4 | ||
|
|
28aba7b27a | ||
|
|
453d5ed066 | ||
|
|
ed3ba0135b | ||
|
|
fb7d945c50 | ||
|
|
9958e1c09c | ||
|
|
f00d48f1c3 | ||
|
|
7edbfd5d76 | ||
|
|
1edb940557 | ||
|
|
c9330e6cb8 | ||
|
|
0e9f652d84 | ||
|
|
d29f5706ce | ||
|
|
b2849ec04a | ||
|
|
bd69cf478c | ||
|
|
5197135d00 | ||
|
|
b09b8649b8 | ||
|
|
99fafdb8d6 | ||
|
|
b02e49c7e0 | ||
|
|
8609635d74 | ||
|
|
5a07f798b3 | ||
|
|
3af69830ee | ||
|
|
a050153bcd | ||
|
|
9d9731c7ce | ||
|
|
dfe3206614 | ||
|
|
97fd882878 | ||
|
|
111e12df12 | ||
|
|
de13c07855 | ||
|
|
29a2e994cd | ||
|
|
f6d886b218 | ||
|
|
80d98b30a1 | ||
|
|
32f2f1431b | ||
|
|
8f6d584ee9 | ||
|
|
1905cbf40c | ||
|
|
c8f668238e | ||
|
|
bf11bacdee | ||
|
|
f3c6a0b8ae | ||
|
|
619c602571 | ||
|
|
600daf44d9 | ||
|
|
a1d6c92806 | ||
|
|
ce1bd6a435 | ||
|
|
79d85227c2 | ||
|
|
885abf3333 | ||
|
|
c0c8fee6be | ||
|
|
cbcd1e51f6 | ||
|
|
114a32536d | ||
|
|
ff8a0f065b | ||
|
|
44fd0ffb91 |
@@ -1,5 +1,7 @@
|
|||||||
.git
|
.git
|
||||||
|
**/.git
|
||||||
.gitmodules
|
.gitmodules
|
||||||
|
**/.gitmodules
|
||||||
.github
|
.github
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.fleet
|
.fleet
|
||||||
@@ -8,6 +10,13 @@
|
|||||||
*.log
|
*.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
|
k8s
|
||||||
|
docs
|
||||||
|
e2e
|
||||||
|
tests
|
||||||
|
|
||||||
|
docker-compose.yml
|
||||||
|
docker/local
|
||||||
|
|
||||||
.phpunit.cache
|
.phpunit.cache
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
@@ -16,6 +25,18 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
blob-report
|
blob-report
|
||||||
playwright/.cache
|
playwright/.cache
|
||||||
|
openapi.json
|
||||||
|
playwright
|
||||||
|
playwright.config.ts
|
||||||
|
vitest.config.ts
|
||||||
|
phpunit.xml
|
||||||
|
phpstan.neon
|
||||||
|
pint.json
|
||||||
|
eslint.config.mjs
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
postcss.config.js
|
||||||
|
tailwind.config.js
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
extensions/*/node_modules
|
extensions/*/node_modules
|
||||||
@@ -30,3 +51,4 @@ _ide_helper.php
|
|||||||
.phpstorm.meta.php
|
.phpstorm.meta.php
|
||||||
|
|
||||||
storage/logs/*
|
storage/logs/*
|
||||||
|
storage/*.key
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
APP_ENABLE_REGISTRATION=true
|
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
|
||||||
|
APP_ENABLE_REGISTRATION=on
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|
||||||
|
|||||||
27
.github/VOUCHED.td
vendored
Normal file
27
.github/VOUCHED.td
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Vouched contributors for solidtime.
|
||||||
|
#
|
||||||
|
# One handle per line, without the leading @, sorted alphabetically.
|
||||||
|
# Prefix a handle with - to denounce them, optionally followed by a reason.
|
||||||
|
# Format reference: https://github.com/mitchellh/vouch
|
||||||
|
#
|
||||||
|
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
|
||||||
|
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
|
||||||
|
# discussion and the vouch workflows will update this file.
|
||||||
|
#
|
||||||
|
# Collaborators with write access and bots are always allowed and do not need
|
||||||
|
# an entry here.
|
||||||
|
#
|
||||||
|
# Seeded 2026-07-25 from the authors of every merged pull request.
|
||||||
|
|
||||||
|
agross
|
||||||
|
akolenda
|
||||||
|
bufferhead-code
|
||||||
|
candideu
|
||||||
|
kasparrosin
|
||||||
|
korridor
|
||||||
|
nikbucher tasks table sorting
|
||||||
|
onatcer
|
||||||
|
shrootbuck
|
||||||
|
smilebeda
|
||||||
|
thespyder
|
||||||
|
utlark
|
||||||
5
.github/workflows/build-onpremise.yml
vendored
5
.github/workflows/build-onpremise.yml
vendored
@@ -8,6 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-onpremise.yml'
|
- '.github/workflows/build-onpremise.yml'
|
||||||
|
- '.dockerignore'
|
||||||
- 'extensions/manifest.json'
|
- 'extensions/manifest.json'
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -140,7 +141,7 @@ jobs:
|
|||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
@@ -194,7 +195,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
|
|||||||
5
.github/workflows/build-private.yml
vendored
5
.github/workflows/build-private.yml
vendored
@@ -8,6 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-private.yml'
|
- '.github/workflows/build-private.yml'
|
||||||
|
- '.dockerignore'
|
||||||
- 'extensions/manifest.json'
|
- 'extensions/manifest.json'
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -96,7 +97,7 @@ jobs:
|
|||||||
- name: "Install dependencies in billing extension"
|
- name: "Install dependencies in billing extension"
|
||||||
uses: php-actions/composer@v6
|
uses: php-actions/composer@v6
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||||
with:
|
with:
|
||||||
working_dir: "extensions/Billing"
|
working_dir: "extensions/Billing"
|
||||||
command: install
|
command: install
|
||||||
@@ -176,7 +177,7 @@ jobs:
|
|||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
registry: rg.fr-par.scw.cloud/solidtime
|
registry: rg.fr-par.scw.cloud/solidtime
|
||||||
username: nologin
|
username: nologin
|
||||||
|
|||||||
9
.github/workflows/build-public.yml
vendored
9
.github/workflows/build-public.yml
vendored
@@ -8,6 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-public.yml'
|
- '.github/workflows/build-public.yml'
|
||||||
|
- '.dockerignore'
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -116,13 +117,13 @@ jobs:
|
|||||||
${{ env.GHCR_REPO }}
|
${{ env.GHCR_REPO }}
|
||||||
|
|
||||||
- name: "Login to Docker Hub Container Registry"
|
- name: "Login to Docker Hub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -176,13 +177,13 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GHCR"
|
- name: "Login to GHCR"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
name: PHPUnit Tests - Extensions
|
||||||
|
on: push
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
phpunit-extensions:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
postgres_version: [ 15, 16, 17 ]
|
||||||
|
|
||||||
|
services:
|
||||||
|
pgsql_test:
|
||||||
|
image: postgres:${{ matrix.postgres_version }}
|
||||||
|
env:
|
||||||
|
PGPASSWORD: 'root'
|
||||||
|
POSTGRES_DB: 'laravel'
|
||||||
|
POSTGRES_USER: 'root'
|
||||||
|
POSTGRES_PASSWORD: 'root'
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
gotenberg:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
options: >-
|
||||||
|
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: "Setup PHP"
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||||
|
coverage: pcov
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Read extension manifest"
|
||||||
|
id: extension-manifest
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
|
||||||
|
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
|
||||||
|
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
|
||||||
|
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
||||||
|
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
||||||
|
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: "Checkout billing extension"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
||||||
|
path: extensions/Billing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in billing extension"
|
||||||
|
working-directory: extensions/Billing
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in billing extension"
|
||||||
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
|
- name: "Checkout services extension"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
||||||
|
path: extensions/Services
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in services extension"
|
||||||
|
working-directory: extensions/Services
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in services extension"
|
||||||
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
|
- name: "Checkout invoicing extension"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
||||||
|
path: extensions/Invoicing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in invoicing extension"
|
||||||
|
working-directory: extensions/Invoicing
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
|
- name: "Run composer install"
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Activate billing extension"
|
||||||
|
run: php artisan module:enable Billing
|
||||||
|
|
||||||
|
- name: "Activate services extension"
|
||||||
|
run: php artisan module:enable Services
|
||||||
|
|
||||||
|
- name: "Activate invoicing extension"
|
||||||
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Build Frontend"
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: "Prepare Laravel Application"
|
||||||
|
run: |
|
||||||
|
cp .env.ci .env
|
||||||
|
php artisan key:generate
|
||||||
|
php artisan passport:keys
|
||||||
|
|
||||||
|
- name: "Run PHPUnit"
|
||||||
|
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||||
75
.github/workflows/vouch-check-pr.yml
vendored
Normal file
75
.github/workflows/vouch-check-pr.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Vouch (check PR)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'pull_request_target' ||
|
||||||
|
(github.event_name == 'issue_comment' &&
|
||||||
|
github.event.issue.pull_request &&
|
||||||
|
contains(github.event.comment.body, '/recheck'))
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
|
||||||
|
- name: "Measure diff size"
|
||||||
|
id: size
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||||
|
|
||||||
|
# Changes to these files do not count towards the 50-line limit.
|
||||||
|
# One extended regex per line, matched against the whole repo-relative
|
||||||
|
# path, so use a leading .* to match a file in any directory.
|
||||||
|
IGNORED: |
|
||||||
|
package-lock\.json
|
||||||
|
composer\.lock
|
||||||
|
tests/.*
|
||||||
|
e2e/.*
|
||||||
|
.*\.(test|spec)\.(ts|js|vue)
|
||||||
|
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# An empty list yields "^()$", which matches no filename. grep exits
|
||||||
|
# 1 on an empty list, so swallow that rather than fail the step.
|
||||||
|
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
|
||||||
|
ignored="^($(join <<<"$IGNORED"))$"
|
||||||
|
|
||||||
|
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
|
||||||
|
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
|
||||||
|
awk -F'\t' -v ignored="$ignored" '
|
||||||
|
$1 ~ ignored { next }
|
||||||
|
{ n += $2 }
|
||||||
|
END { print n+0 }')
|
||||||
|
echo "total=$total" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Countable diff size: $total line(s)"
|
||||||
|
|
||||||
|
- name: "Small patch (denounced users still blocked)"
|
||||||
|
if: fromJSON(steps.size.outputs.total) <= 50
|
||||||
|
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
||||||
|
with:
|
||||||
|
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||||
|
auto-close: true
|
||||||
|
require-vouch: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Full vouch required"
|
||||||
|
if: fromJSON(steps.size.outputs.total) > 50
|
||||||
|
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
||||||
|
with:
|
||||||
|
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||||
|
auto-close: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
Normal file
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Vouch (manage by discussion)
|
||||||
|
|
||||||
|
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
|
||||||
|
|
||||||
|
on:
|
||||||
|
discussion_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vouch-manage
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
discussions: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
manage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: "Apply vouch command"
|
||||||
|
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
|
||||||
|
with:
|
||||||
|
discussion-number: ${{ github.event.discussion.number }}
|
||||||
|
comment-node-id: ${{ github.event.comment.node_id }}
|
||||||
|
roles: admin,maintain,write
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
35
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
35
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Vouch (manage by issue)
|
||||||
|
|
||||||
|
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
|
||||||
|
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vouch-manage
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
manage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: "Apply vouch command"
|
||||||
|
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
|
||||||
|
with:
|
||||||
|
issue-id: ${{ github.event.issue.number }}
|
||||||
|
comment-id: ${{ github.event.comment.id }}
|
||||||
|
roles: admin,maintain,write
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -12,6 +12,22 @@ In order to keep the issues of the repository clean we decided to only use them
|
|||||||
|
|
||||||
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||||
|
|
||||||
|
### Vouched contributors
|
||||||
|
|
||||||
|
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
|
||||||
|
|
||||||
|
Your pull request is not affected if any of the following applies:
|
||||||
|
|
||||||
|
- You have write access to this repository.
|
||||||
|
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
|
||||||
|
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
|
||||||
|
|
||||||
|
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
|
||||||
|
|
||||||
|
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
|
||||||
|
|
||||||
|
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
|
||||||
|
|
||||||
### Contributor License Agreement
|
### Contributor License Agreement
|
||||||
|
|
||||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Please open an issue or start a discussion and wait for approval before submitti
|
|||||||
|
|
||||||
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
||||||
|
|
||||||
|
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
|
||||||
|
|
||||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Enums\RegistrationMode;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Events\NewsletterRegistered;
|
use App\Events\NewsletterRegistered;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\InvitationService;
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use App\Service\UserService;
|
use App\Service\UserService;
|
||||||
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
if (! config('app.enable_registration')) {
|
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
|
||||||
|
if ($registrationMode === RegistrationMode::Off) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => [__('Registration is disabled.')],
|
'email' => [__('Registration is disabled.')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Validator::make($input, [
|
$validated = Validator::make($input, [
|
||||||
'name' => [
|
'name' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -60,6 +63,20 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
],
|
],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
|
if ($registrationMode === RegistrationMode::InviteOnly) {
|
||||||
|
$invitationService = app(InvitationService::class);
|
||||||
|
$email = (string) $validated['email'];
|
||||||
|
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
|
||||||
|
$message = $invitationService->hasPendingInvitationForEmail($email)
|
||||||
|
? __('Please accept the organization invitation sent to your email address before registering.')
|
||||||
|
: __('Registration is only available to invited users.');
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [$message],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$timezone = null;
|
$timezone = null;
|
||||||
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
||||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||||
|
|||||||
37
app/Auth/ActiveUserProvider.php
Normal file
37
app/Auth/ActiveUserProvider.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\EloquentUserProvider;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User provider that only resolves non-placeholder users.
|
||||||
|
*
|
||||||
|
* Placeholder users are created by imports and when members are removed from an
|
||||||
|
* organization. They can share an email address with a real user, so resolving a user by
|
||||||
|
* email can return a placeholder instead of the real account. The login flow filters them
|
||||||
|
* out explicitly, but the password broker and the guard credential checks (for example the
|
||||||
|
* password confirmation) resolve users through the configured user provider.
|
||||||
|
*
|
||||||
|
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
|
||||||
|
* built-in one for every provider in config/auth.php.
|
||||||
|
*/
|
||||||
|
class ActiveUserProvider extends EloquentUserProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Model|null $model
|
||||||
|
* @return Builder<Model>
|
||||||
|
*/
|
||||||
|
#[\Override]
|
||||||
|
protected function newModelQuery($model = null): Builder
|
||||||
|
{
|
||||||
|
$query = parent::newModelQuery($model);
|
||||||
|
$query->getQuery()->where('is_placeholder', '=', false);
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
|||||||
])
|
])
|
||||||
->whereHas('user', function (Builder $query): void {
|
->whereHas('user', function (Builder $query): void {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
$query->where('is_placeholder', '=', false);
|
$query->where('is_placeholder', '=', false)
|
||||||
|
->where('send_time_entry_still_running_email', '=', true);
|
||||||
})
|
})
|
||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||||
|
|||||||
29
app/Enums/RegistrationMode.php
Normal file
29
app/Enums/RegistrationMode.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum RegistrationMode: string
|
||||||
|
{
|
||||||
|
case On = 'on';
|
||||||
|
case InviteOnly = 'invite-only';
|
||||||
|
case Off = 'off';
|
||||||
|
|
||||||
|
public static function fromConfig(mixed $value): self
|
||||||
|
{
|
||||||
|
if ($value === true) {
|
||||||
|
return self::On;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === false || $value === null) {
|
||||||
|
return self::Off;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (strtolower(trim((string) $value))) {
|
||||||
|
'1', 'on', 'true' => self::On,
|
||||||
|
'invite-only' => self::InviteOnly,
|
||||||
|
default => self::Off,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
|
|||||||
*/
|
*/
|
||||||
public function report(): bool
|
public function report(): bool
|
||||||
{
|
{
|
||||||
// TODO: temporary activated
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class CanNotRemoveOwnerFromOrganization extends ApiException
|
class CanNotRemoveOwnerFromOrganization extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'can_not_remove_owner_from_organization';
|
public const string KEY = 'can_not_remove_owner_from_organization';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ class EntityStillInUseApiException extends ApiException
|
|||||||
|
|
||||||
public const string KEY = 'entity_still_in_use';
|
public const string KEY = 'entity_still_in_use';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the exception.
|
||||||
|
*
|
||||||
|
* @return bool true means the exception handler will not report it again
|
||||||
|
*/
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the translated message for the exception.
|
* Get the translated message for the exception.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'feature_is_not_available_in_free_plan';
|
public const string KEY = 'feature_is_not_available_in_free_plan';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class InactiveUserCanNotBeUsedApiException extends ApiException
|
class InactiveUserCanNotBeUsedApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'inactive_user_can_not_be_used';
|
public const string KEY = 'inactive_user_can_not_be_used';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'invitation_for_the_email_already_exists';
|
public const string KEY = 'invitation_for_the_email_already_exists';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OnlyOwnerCanChangeOwnership extends ApiException
|
class OnlyOwnerCanChangeOwnership extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'only_owner_can_change_ownership';
|
public const string KEY = 'only_owner_can_change_ownership';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'organization_needs_at_least_one_owner';
|
public const string KEY = 'organization_needs_at_least_one_owner';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class OverlappingTimeEntryApiException extends ApiException
|
class OverlappingTimeEntryApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'overlapping_time_entry';
|
public const string KEY = 'overlapping_time_entry';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
class TimeEntryCanNotBeRestartedApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'time_entry_can_not_be_restarted';
|
public const string KEY = 'time_entry_can_not_be_restarted';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
|
|||||||
class TimeEntryStillRunningApiException extends ApiException
|
class TimeEntryStillRunningApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'time_entry_still_running';
|
public const string KEY = 'time_entry_still_running';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the exception.
|
||||||
|
*
|
||||||
|
* @return bool true means the exception handler will not report it again
|
||||||
|
*/
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_is_already_member_of_organization';
|
public const string KEY = 'user_is_already_member_of_organization';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_is_already_member_of_project';
|
public const string KEY = 'user_is_already_member_of_project';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserNotPlaceholderApiException extends ApiException
|
class UserNotPlaceholderApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_not_placeholder';
|
public const string KEY = 'user_not_placeholder';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
|
|||||||
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||||
{
|
{
|
||||||
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ namespace App\Exceptions;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||||
|
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class Handler extends ExceptionHandler
|
class Handler extends ExceptionHandler
|
||||||
@@ -30,6 +34,33 @@ class Handler extends ExceptionHandler
|
|||||||
$this->reportable(function (Throwable $e): void {
|
$this->reportable(function (Throwable $e): void {
|
||||||
//
|
//
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
|
||||||
|
&& $e->getErrorType() === 'access_denied'
|
||||||
|
&& $e->getHttpStatusCode() === 401);
|
||||||
|
|
||||||
|
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
|
||||||
|
// otherwise renders as a bare "Bad request." 400. Show a message that
|
||||||
|
// says how to fix it instead. The framework has already converted the
|
||||||
|
// SuspiciousOperationException into a BadRequestHttpException by the time
|
||||||
|
// renderables run, so we match that and inspect the original.
|
||||||
|
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
|
||||||
|
$previous = $e->getPrevious();
|
||||||
|
|
||||||
|
if (! $previous instanceof SuspiciousOperationException
|
||||||
|
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
|
||||||
|
return null; // any other bad request keeps the default response
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = 'This hostname is not configured for this instance. '
|
||||||
|
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['message' => $message], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->view('errors.untrusted-host', ['message' => $message], 400);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render($request, Throwable $e): Response|RedirectResponse
|
public function render($request, Throwable $e): Response|RedirectResponse
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class ExportController extends Controller
|
|||||||
|
|
||||||
$filepath = $exportService->export($organization);
|
$filepath = $exportService->export($organization);
|
||||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
|
||||||
|
]);
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$query = TimeEntry::query()
|
$query = TimeEntry::query()
|
||||||
->where('organization_id', $organization->getKey())
|
->where('organization_id', $organization->getKey())
|
||||||
->where('user_id', $member->user_id)
|
->where('member_id', $member->getKey())
|
||||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||||
$q->where('id', '!=', $exclude->getKey());
|
$q->where('id', '!=', $exclude->getKey());
|
||||||
})
|
})
|
||||||
@@ -108,8 +108,8 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get time entries in organization
|
* Get time entries in organization
|
||||||
*
|
*
|
||||||
* If you only need time entries for a specific user, you can filter by `user_id`.
|
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
||||||
*
|
*
|
||||||
* @return TimeEntryCollection<TimeEntryResource>
|
* @return TimeEntryCollection<TimeEntryResource>
|
||||||
*
|
*
|
||||||
@@ -119,16 +119,17 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -159,7 +160,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($timeEntries->count() === 0) {
|
if ($timeEntries->count() === 0) {
|
||||||
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
||||||
'date' => $lastDate->toDateString(),
|
'date' => $lastDate->toDateString(),
|
||||||
'user_id' => $request->input('user_id'),
|
'member_id' => $request->input('member_id'),
|
||||||
'auth_user_id' => Auth::id(),
|
'auth_user_id' => Auth::id(),
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
]);
|
]);
|
||||||
@@ -223,9 +224,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -242,7 +244,7 @@ class TimeEntryController extends Controller
|
|||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -265,7 +267,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($viewFile === false) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
null,
|
null,
|
||||||
@@ -334,7 +336,9 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5)),
|
->temporaryUrl($path, now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,9 +376,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -385,7 +390,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -421,9 +426,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $memberFilter */
|
||||||
if ($member !== null && $member->user_id === Auth::id()) {
|
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -439,7 +445,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -531,7 +537,7 @@ class TimeEntryController extends Controller
|
|||||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
||||||
} else {
|
} else {
|
||||||
Excel::store(
|
Excel::store(
|
||||||
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
|
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
|
||||||
$path,
|
$path,
|
||||||
config('filesystems.private'),
|
config('filesystems.private'),
|
||||||
$format->getExportPackageType(),
|
$format->getExportPackageType(),
|
||||||
@@ -543,7 +549,9 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5)),
|
->temporaryUrl($path, now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +591,7 @@ class TimeEntryController extends Controller
|
|||||||
{
|
{
|
||||||
/** @var Member $member */
|
/** @var Member $member */
|
||||||
$member = Member::query()->findOrFail($request->input('member_id'));
|
$member = Member::query()->findOrFail($request->input('member_id'));
|
||||||
if ($member->user_id === Auth::id()) {
|
if ($member->getKey() === $this->member($organization)->getKey()) {
|
||||||
$this->checkPermission($organization, 'time-entries:create:own');
|
$this->checkPermission($organization, 'time-entries:create:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:create:all');
|
$this->checkPermission($organization, 'time-entries:create:all');
|
||||||
@@ -630,9 +638,10 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
/** @var Member|null $member */
|
$member = $this->member($organization);
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
/** @var Member|null $newMember */
|
||||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
|
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
|
||||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||||
@@ -664,6 +673,10 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
|
if ($newMember !== null) {
|
||||||
|
$timeEntry->member()->associate($newMember);
|
||||||
|
$timeEntry->user()->associate($newMember->user);
|
||||||
|
}
|
||||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
@@ -693,6 +706,7 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$member = $this->member($organization);
|
||||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||||
|
|
||||||
@@ -717,6 +731,9 @@ class TimeEntryController extends Controller
|
|||||||
throw new AuthorizationException;
|
throw new AuthorizationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var Member|null $newMember */
|
||||||
|
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
|
||||||
|
|
||||||
$project = null;
|
$project = null;
|
||||||
$client = null;
|
$client = null;
|
||||||
$overwriteClient = false;
|
$overwriteClient = false;
|
||||||
@@ -743,7 +760,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -766,6 +783,10 @@ class TimeEntryController extends Controller
|
|||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
$timeEntry->fill($changes);
|
$timeEntry->fill($changes);
|
||||||
|
if ($newMember !== null) {
|
||||||
|
$timeEntry->member()->associate($newMember);
|
||||||
|
$timeEntry->user_id = $newMember->user_id;
|
||||||
|
}
|
||||||
// If project is changed, but task is not, we remove the old task from the time entry
|
// If project is changed, but task is not, we remove the old task from the time entry
|
||||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||||
$timeEntry->task()->disassociate();
|
$timeEntry->task()->disassociate();
|
||||||
@@ -806,7 +827,8 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||||
{
|
{
|
||||||
if ($timeEntry->member->user_id === Auth::id()) {
|
$member = $this->member($organization);
|
||||||
|
if ($timeEntry->member_id === $member->getKey()) {
|
||||||
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||||
@@ -863,7 +885,7 @@ class TimeEntryController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ class UserController extends Controller
|
|||||||
$user->week_start = $request->getWeekStart();
|
$user->week_start = $request->getWeekStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
|
||||||
|
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
|
||||||
|
}
|
||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
if ($emailToVerify !== null) {
|
if ($emailToVerify !== null) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return redirect(route('register'))
|
return redirect(route('register'))
|
||||||
|
->with('registration_email', $email)
|
||||||
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
||||||
'organization' => $organization->name,
|
'organization' => $organization->name,
|
||||||
]))
|
]))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
|||||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||||
use App\Http\Middleware\ShareInertiaData;
|
use App\Http\Middleware\ShareInertiaData;
|
||||||
use App\Http\Middleware\TrimStrings;
|
use App\Http\Middleware\TrimStrings;
|
||||||
|
use App\Http\Middleware\TrustHosts;
|
||||||
use App\Http\Middleware\TrustProxies;
|
use App\Http\Middleware\TrustProxies;
|
||||||
use App\Http\Middleware\ValidateSignature;
|
use App\Http\Middleware\ValidateSignature;
|
||||||
use App\Http\Middleware\VerifyCsrfToken;
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
@@ -47,6 +48,7 @@ class Kernel extends HttpKernel
|
|||||||
*/
|
*/
|
||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
ForceHttps::class,
|
ForceHttps::class,
|
||||||
|
TrustHosts::class,
|
||||||
TrustProxies::class,
|
TrustProxies::class,
|
||||||
HandleCors::class,
|
HandleCors::class,
|
||||||
PreventRequestsDuringMaintenance::class,
|
PreventRequestsDuringMaintenance::class,
|
||||||
|
|||||||
56
app/Http/Middleware/TrustHosts.php
Normal file
56
app/Http/Middleware/TrustHosts.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects requests whose Host is not trusted, preventing Host-header poisoning of
|
||||||
|
* generated URLs (password reset, SSO callback, invitations). Trusted = the
|
||||||
|
* APP_URL host and its subdomains, plus TRUSTED_HOSTS (for multi-host access such
|
||||||
|
* as a Tailscale name). Health-check endpoints are exempt (probed by IP).
|
||||||
|
*/
|
||||||
|
class TrustHosts extends BaseTrustHosts
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string|null>
|
||||||
|
*/
|
||||||
|
public function hosts(): array
|
||||||
|
{
|
||||||
|
/** @var array<int, string> $configured */
|
||||||
|
$configured = config('app.trusted_hosts', []);
|
||||||
|
|
||||||
|
$extra = array_map(function (string $host): string {
|
||||||
|
$host = trim($host);
|
||||||
|
|
||||||
|
// "*.example.com" matches any subdomain, not the apex.
|
||||||
|
if (str_starts_with($host, '*.')) {
|
||||||
|
return '^.+\.'.preg_quote(substr($host, 2), '#').'$';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '^'.preg_quote($host, '#').'$';
|
||||||
|
}, $configured);
|
||||||
|
|
||||||
|
return array_merge([$this->allSubdomainsOfApplicationUrl()], $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Closure(Request): Response $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, $next)
|
||||||
|
{
|
||||||
|
// Exempt health checks (probed by IP). Also reset the trusted hosts,
|
||||||
|
// since Octane leaks the static state across requests.
|
||||||
|
if ($request->is('health-check/*')) {
|
||||||
|
Request::setTrustedHosts([]);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::handle($request, $next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class ImportRequest extends BaseFormRequest
|
|||||||
'data' => [
|
'data' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
|
'max:'.config('import.max_data_size'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
'week_start' => [
|
'week_start' => [
|
||||||
Rule::enum(Weekday::class),
|
Rule::enum(Weekday::class),
|
||||||
],
|
],
|
||||||
|
'send_time_entry_still_running_email' => [
|
||||||
|
'boolean',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +84,13 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSendTimeEntryStillRunningEmail(): ?bool
|
||||||
|
{
|
||||||
|
return $this->has('send_time_entry_still_running_email')
|
||||||
|
? $this->boolean('send_time_entry_still_running_email')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
public function hasPhotoKey(): bool
|
public function hasPhotoKey(): bool
|
||||||
{
|
{
|
||||||
return $this->has('photo');
|
return $this->has('photo');
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class UserResource extends BaseResource
|
|||||||
'timezone' => $this->resource->timezone,
|
'timezone' => $this->resource->timezone,
|
||||||
/** @var Weekday $week_start Starting day of the week */
|
/** @var Weekday $week_start Starting day of the week */
|
||||||
'week_start' => $this->resource->week_start->value,
|
'week_start' => $this->resource->week_start->value,
|
||||||
|
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
|
||||||
|
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string|null $pending_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 $remember_token
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
|
* @property string|null $two_factor_recovery_codes
|
||||||
|
* @property Carbon|null $two_factor_confirmed_at
|
||||||
* @property string $timezone
|
* @property string $timezone
|
||||||
* @property bool $is_placeholder
|
* @property bool $is_placeholder
|
||||||
* @property Weekday $week_start
|
* @property Weekday $week_start
|
||||||
|
* @property bool $send_time_entry_still_running_email
|
||||||
* @property string|null $profile_photo_path
|
* @property string|null $profile_photo_path
|
||||||
* @property-read Organization|null $currentOrganization
|
* @property-read Organization|null $currentOrganization
|
||||||
* @property-read string $profile_photo_url
|
* @property-read string $profile_photo_url
|
||||||
@@ -108,6 +112,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
'is_placeholder' => 'boolean',
|
'is_placeholder' => 'boolean',
|
||||||
'week_start' => Weekday::class,
|
'week_start' => Weekday::class,
|
||||||
|
'send_time_entry_still_running_email' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +122,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
*/
|
*/
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'week_start' => Weekday::Monday,
|
'week_start' => Weekday::Monday,
|
||||||
|
'send_time_entry_still_running_email' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,7 +153,9 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
return $this->is_placeholder === false
|
||||||
|
&& in_array($this->email, config('auth.super_admins', []), true)
|
||||||
|
&& $this->hasVerifiedEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isMemberOfOrganization(Organization $organization): bool
|
public function isMemberOfOrganization(Organization $organization): bool
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Service\BillingContract;
|
|||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\IpLookup\NoIpLookupService;
|
use App\Service\IpLookup\NoIpLookupService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
|
use DateTimeInterface;
|
||||||
use Dedoc\Scramble\Scramble;
|
use Dedoc\Scramble\Scramble;
|
||||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||||
@@ -29,8 +30,13 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -98,6 +104,27 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||||
$this->app->bind(BillingContract::class);
|
$this->app->bind(BillingContract::class);
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||||
|
// so mirror it through the signed query parameters of the storage route.
|
||||||
|
$privateDisk = config('filesystems.private');
|
||||||
|
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
|
||||||
|
$disk = Storage::disk($privateDisk);
|
||||||
|
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
|
||||||
|
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
|
||||||
|
});
|
||||||
|
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
|
||||||
|
$parameters = array_filter([
|
||||||
|
'path' => $path,
|
||||||
|
'disposition' => isset($options['ResponseContentDisposition'])
|
||||||
|
? Str::before($options['ResponseContentDisposition'], ';')
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Routing
|
// Routing
|
||||||
Route::model('member', Member::class);
|
Route::model('member', Member::class);
|
||||||
Route::model('invitation', OrganizationInvitation::class);
|
Route::model('invitation', OrganizationInvitation::class);
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Auth\ActiveUserProvider;
|
||||||
use App\Models\Passport\AuthCode;
|
use App\Models\Passport\AuthCode;
|
||||||
use App\Models\Passport\Client;
|
use App\Models\Passport\Client;
|
||||||
use App\Models\Passport\RefreshToken;
|
use App\Models\Passport\RefreshToken;
|
||||||
use App\Models\Passport\Token;
|
use App\Models\Passport\Token;
|
||||||
|
use Illuminate\Contracts\Foundation\Application;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
class AuthServiceProvider extends ServiceProvider
|
||||||
@@ -26,6 +29,13 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
// Replaces the built-in eloquent user provider, so that no authentication flow can
|
||||||
|
// resolve a placeholder user. The driver name is kept, because Passport recognizes
|
||||||
|
// only providers that are configured with the driver "eloquent".
|
||||||
|
Auth::provider('eloquent', function (Application $app, array $config): ActiveUserProvider {
|
||||||
|
return new ActiveUserProvider($app->make('hash'), $config['model']);
|
||||||
|
});
|
||||||
|
|
||||||
// define scopes for passport tokens
|
// define scopes for passport tokens
|
||||||
Passport::tokensCan([
|
Passport::tokensCan([
|
||||||
'create' => 'Create resources',
|
'create' => 'Create resources',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
Fortify::registerView(function () {
|
Fortify::registerView(function () {
|
||||||
return Inertia::render('Auth/Register', [
|
return Inertia::render('Auth/Register', [
|
||||||
|
'email' => session('registration_email', ''),
|
||||||
'terms_url' => config('auth.terms_url'),
|
'terms_url' => config('auth.terms_url'),
|
||||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $request->user()
|
return $request->user()
|
||||||
? Limit::perMinute(200)->by($request->user()->id)
|
? Limit::perMinute(config('app.api_rate_limit_authenticated_per_minute'))->by($request->user()->id)
|
||||||
: Limit::perMinute(60)->by($request->ip());
|
: Limit::perMinute(config('app.api_rate_limit_guest_per_minute'))->by($request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->routes(function (): void {
|
$this->routes(function (): void {
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ use App\Service\Import\Importers\ImporterContract;
|
|||||||
use App\Service\Import\Importers\ImporterProvider;
|
use App\Service\Import\Importers\ImporterProvider;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\ReportDto;
|
use App\Service\Import\Importers\ReportDto;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class ImportService
|
class ImportService
|
||||||
{
|
{
|
||||||
@@ -25,8 +22,6 @@ class ImportService
|
|||||||
/** @var ImporterContract $importer */
|
/** @var ImporterContract $importer */
|
||||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
Storage::disk(config('filesystems.default'))
|
|
||||||
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
|
|
||||||
|
|
||||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use Illuminate\Support\Str;
|
|||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ZipArchive;
|
|
||||||
|
|
||||||
class SolidtimeImporter extends DefaultImporter
|
class SolidtimeImporter extends DefaultImporter
|
||||||
{
|
{
|
||||||
@@ -34,16 +33,10 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$temporaryDirectoryZip = null;
|
$temporaryDirectoryZip = null;
|
||||||
$temporaryDirectory = null;
|
$temporaryDirectory = null;
|
||||||
try {
|
try {
|
||||||
$zip = new ZipArchive;
|
|
||||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
|
||||||
if ($res !== true) {
|
|
||||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
|
||||||
}
|
|
||||||
$temporaryDirectory = TemporaryDirectory::make();
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
$zip->extractTo($temporaryDirectory->path());
|
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
||||||
throw new ImportException('File "meta.json" missing in ZIP');
|
throw new ImportException('File "meta.json" missing in ZIP');
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use Illuminate\Support\Str;
|
|||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ValueError;
|
use ValueError;
|
||||||
use ZipArchive;
|
|
||||||
|
|
||||||
class TogglDataImporter extends DefaultImporter
|
class TogglDataImporter extends DefaultImporter
|
||||||
{
|
{
|
||||||
@@ -26,16 +25,10 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
$temporaryDirectoryZip = null;
|
$temporaryDirectoryZip = null;
|
||||||
$temporaryDirectory = null;
|
$temporaryDirectory = null;
|
||||||
try {
|
try {
|
||||||
$zip = new ZipArchive;
|
|
||||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
|
||||||
if ($res !== true) {
|
|
||||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
|
||||||
}
|
|
||||||
$temporaryDirectory = TemporaryDirectory::make();
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
$zip->extractTo($temporaryDirectory->path());
|
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
||||||
$zip->close();
|
|
||||||
if (! file_exists($temporaryDirectory->path('clients.json'))) {
|
if (! file_exists($temporaryDirectory->path('clients.json'))) {
|
||||||
throw new ImportException('File "clients.json" missing in ZIP');
|
throw new ImportException('File "clients.json" missing in ZIP');
|
||||||
}
|
}
|
||||||
|
|||||||
129
app/Service/Import/Importers/ZipImportHelper.php
Normal file
129
app/Service/Import/Importers/ZipImportHelper.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts uploaded ZIP archives with limits on file count, total uncompressed
|
||||||
|
* size and entry paths, so a small malicious archive can not fill the disk
|
||||||
|
* (decompression bomb) or write outside the target directory (zip slip).
|
||||||
|
*/
|
||||||
|
class ZipImportHelper
|
||||||
|
{
|
||||||
|
private const int CHUNK_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
public function extract(string $zipPath, string $targetPath): void
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$res = $zip->open($zipPath, ZipArchive::RDONLY);
|
||||||
|
if ($res !== true) {
|
||||||
|
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$maxFiles = (int) config('import.zip_max_files');
|
||||||
|
$maxUncompressedSize = (int) config('import.zip_max_uncompressed_size');
|
||||||
|
|
||||||
|
if ($zip->numFiles > $maxFiles) {
|
||||||
|
throw new ImportException('ZIP contains too many files, maximum is '.$maxFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the sizes declared in the archive before writing anything to disk
|
||||||
|
$declaredSize = 0;
|
||||||
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
|
$stat = $zip->statIndex($index);
|
||||||
|
if ($stat === false) {
|
||||||
|
throw new ImportException('Invalid ZIP entry');
|
||||||
|
}
|
||||||
|
$this->validateEntryName($stat['name']);
|
||||||
|
$declaredSize += $stat['size'];
|
||||||
|
if ($declaredSize > $maxUncompressedSize) {
|
||||||
|
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The declared sizes can be forged, so the written bytes are counted as well
|
||||||
|
$writtenSize = 0;
|
||||||
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
|
$stat = $zip->statIndex($index);
|
||||||
|
if ($stat === false) {
|
||||||
|
throw new ImportException('Invalid ZIP entry');
|
||||||
|
}
|
||||||
|
$name = $stat['name'];
|
||||||
|
$entryPath = $targetPath.DIRECTORY_SEPARATOR.$name;
|
||||||
|
|
||||||
|
if (str_ends_with($name, '/')) {
|
||||||
|
$this->ensureDirectoryExists($entryPath);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->ensureDirectoryExists(dirname($entryPath));
|
||||||
|
|
||||||
|
$stream = $zip->getStreamIndex($index);
|
||||||
|
if ($stream === false) {
|
||||||
|
throw new ImportException('ZIP entry "'.$name.'" can not be read');
|
||||||
|
}
|
||||||
|
$target = fopen($entryPath, 'wb');
|
||||||
|
if ($target === false) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new ImportException('ZIP entry "'.$name.'" can not be extracted');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
while (! feof($stream)) {
|
||||||
|
$chunk = fread($stream, self::CHUNK_SIZE);
|
||||||
|
if ($chunk === false) {
|
||||||
|
throw new ImportException('ZIP entry "'.$name.'" can not be read');
|
||||||
|
}
|
||||||
|
$writtenSize += strlen($chunk);
|
||||||
|
if ($writtenSize > $maxUncompressedSize) {
|
||||||
|
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
|
||||||
|
}
|
||||||
|
fwrite($target, $chunk);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($target);
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function validateEntryName(string $name): void
|
||||||
|
{
|
||||||
|
if ($name === '' || str_contains($name, "\0") || str_contains($name, '\\') || str_starts_with($name, '/')) {
|
||||||
|
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
|
||||||
|
}
|
||||||
|
if (preg_match('/^[a-zA-Z]:/', $name) === 1) {
|
||||||
|
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
|
||||||
|
}
|
||||||
|
foreach (explode('/', rtrim($name, '/')) as $segment) {
|
||||||
|
if ($segment === '' || $segment === '..') {
|
||||||
|
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function ensureDirectoryExists(string $path): void
|
||||||
|
{
|
||||||
|
if (is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! mkdir($path, 0700, true) && ! is_dir($path)) {
|
||||||
|
throw new ImportException('Directory "'.$path.'" can not be created');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,31 @@ use Illuminate\Support\Facades\Mail;
|
|||||||
|
|
||||||
class InvitationService
|
class InvitationService
|
||||||
{
|
{
|
||||||
|
public function hasAcceptedInvitationForEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return OrganizationInvitation::query()
|
||||||
|
->whereRaw('lower(email) = ?', [strtolower($email)])
|
||||||
|
->whereNotNull('accepted_at')
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPendingInvitationForEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return OrganizationInvitation::query()
|
||||||
|
->whereRaw('lower(email) = ?', [strtolower($email)])
|
||||||
|
->whereNull('accepted_at')
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
|
||||||
{
|
{
|
||||||
|
// Normalize the email so it matches how user emails are stored (see UserService::createUser),
|
||||||
|
// otherwise a mixed-case invite silently fails to link on registration.
|
||||||
|
$email = strtolower($email);
|
||||||
|
|
||||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
@@ -55,7 +75,7 @@ class InvitationService
|
|||||||
$organizations = new Collection;
|
$organizations = new Collection;
|
||||||
|
|
||||||
$invitations = OrganizationInvitation::query()
|
$invitations = OrganizationInvitation::query()
|
||||||
->where('email', $user->email)
|
->whereRaw('lower(email) = ?', [strtolower($user->email)])
|
||||||
->whereNotNull('accepted_at')
|
->whereNotNull('accepted_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ use App\Enums\CurrencyFormat;
|
|||||||
use App\Enums\DateFormat;
|
use App\Enums\DateFormat;
|
||||||
use App\Enums\IntervalFormat;
|
use App\Enums\IntervalFormat;
|
||||||
use App\Enums\NumberFormat;
|
use App\Enums\NumberFormat;
|
||||||
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use Brick\Math\BigDecimal;
|
use Brick\Math\BigDecimal;
|
||||||
use Brick\Money\Money;
|
use Brick\Money\Money;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Carbon\CarbonInterval;
|
use Carbon\CarbonInterval;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class LocalizationService
|
class LocalizationService
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,38 @@ class LocalizationService
|
|||||||
return $date->format($this->dateFormat->toCarbonFormat());
|
return $date->format($this->dateFormat->toCarbonFormat());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time group types have no server-side descriptor; their keys are ISO dates and are
|
||||||
|
* formatted here instead. A Week key is the first day of that week, so it renders as the
|
||||||
|
* range it covers. A Year key is already a bare year, so it is returned unchanged - it must
|
||||||
|
* not be parsed, Carbon reads a four digit string as a time of day.
|
||||||
|
*/
|
||||||
|
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groupType === TimeEntryAggregationType::Day) {
|
||||||
|
return $this->formatDate(Carbon::parse($key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groupType === TimeEntryAggregationType::Week) {
|
||||||
|
$weekStart = Carbon::parse($key);
|
||||||
|
|
||||||
|
return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groupType === TimeEntryAggregationType::Month) {
|
||||||
|
// Note: the leading "!" resets all fields the format does not name. Without it the
|
||||||
|
// day of the month is taken from today, and a day that the parsed month does not
|
||||||
|
// have overflows the date into the next month.
|
||||||
|
return Carbon::createFromFormat('!Y-m', $key)->format('F Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
public function setDateFormat(DateFormat $dateFormat): void
|
public function setDateFormat(DateFormat $dateFormat): void
|
||||||
{
|
{
|
||||||
$this->dateFormat = $dateFormat;
|
$this->dateFormat = $dateFormat;
|
||||||
|
|||||||
@@ -218,7 +218,16 @@ class MemberService
|
|||||||
|
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
$placeholderUser->is_placeholder = true;
|
$placeholderUser->is_placeholder = true;
|
||||||
$placeholderUser->current_team_id = $member->organization_id;
|
// Reset authentication relevant properties on the placeholder user
|
||||||
|
$placeholderUser->password = null;
|
||||||
|
$placeholderUser->remember_token = null;
|
||||||
|
$placeholderUser->two_factor_secret = null;
|
||||||
|
$placeholderUser->two_factor_recovery_codes = null;
|
||||||
|
$placeholderUser->two_factor_confirmed_at = null;
|
||||||
|
$placeholderUser->email_verified_at = null;
|
||||||
|
$placeholderUser->pending_email = null;
|
||||||
|
$placeholderUser->current_team_id = null;
|
||||||
|
$placeholderUser->profile_photo_path = null;
|
||||||
$placeholderUser->save();
|
$placeholderUser->save();
|
||||||
|
|
||||||
$member->user()->associate($placeholderUser);
|
$member->user()->associate($placeholderUser);
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ class PermissionStore
|
|||||||
'invoices:update',
|
'invoices:update',
|
||||||
'invoices:download',
|
'invoices:download',
|
||||||
'invoices:delete',
|
'invoices:delete',
|
||||||
|
'invoice-recipients:view',
|
||||||
|
'invoice-recipients:create',
|
||||||
|
'invoice-recipients:update',
|
||||||
|
'invoice-recipients:delete',
|
||||||
'invoice-settings:view',
|
'invoice-settings:view',
|
||||||
'invoice-settings:update',
|
'invoice-settings:update',
|
||||||
],
|
],
|
||||||
@@ -147,6 +151,10 @@ class PermissionStore
|
|||||||
'invoices:update',
|
'invoices:update',
|
||||||
'invoices:download',
|
'invoices:download',
|
||||||
'invoices:delete',
|
'invoices:delete',
|
||||||
|
'invoice-recipients:view',
|
||||||
|
'invoice-recipients:create',
|
||||||
|
'invoice-recipients:update',
|
||||||
|
'invoice-recipients:delete',
|
||||||
'invoice-settings:view',
|
'invoice-settings:view',
|
||||||
'invoice-settings:update',
|
'invoice-settings:update',
|
||||||
],
|
],
|
||||||
@@ -203,6 +211,10 @@ class PermissionStore
|
|||||||
'invoices:update',
|
'invoices:update',
|
||||||
'invoices:download',
|
'invoices:download',
|
||||||
'invoices:delete',
|
'invoices:delete',
|
||||||
|
'invoice-recipients:view',
|
||||||
|
'invoice-recipients:create',
|
||||||
|
'invoice-recipients:update',
|
||||||
|
'invoice-recipients:delete',
|
||||||
'invoice-settings:view',
|
'invoice-settings:view',
|
||||||
'invoice-settings:update',
|
'invoice-settings:update',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Service\ReportExport;
|
|||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
|
use App\Service\LocalizationService;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Maatwebsite\Excel\Concerns\Exportable;
|
use Maatwebsite\Excel\Concerns\Exportable;
|
||||||
use Maatwebsite\Excel\Concerns\FromView;
|
use Maatwebsite\Excel\Concerns\FromView;
|
||||||
@@ -48,6 +49,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
|
|
||||||
private bool $showBillableRate;
|
private bool $showBillableRate;
|
||||||
|
|
||||||
|
private LocalizationService $localization;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
@@ -68,7 +71,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
* cost: int|null
|
* cost: int|null
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
|
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate, LocalizationService $localization)
|
||||||
{
|
{
|
||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
$this->exportFormat = $exportFormat;
|
$this->exportFormat = $exportFormat;
|
||||||
@@ -76,6 +79,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
$this->group = $group;
|
$this->group = $group;
|
||||||
$this->subGroup = $subGroup;
|
$this->subGroup = $subGroup;
|
||||||
$this->showBillableRate = $showBillableRate;
|
$this->showBillableRate = $showBillableRate;
|
||||||
|
$this->localization = $localization;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(): View
|
public function view(): View
|
||||||
@@ -87,6 +91,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
'subGroup' => $this->subGroup,
|
'subGroup' => $this->subGroup,
|
||||||
'exportFormat' => $this->exportFormat,
|
'exportFormat' => $this->exportFormat,
|
||||||
'showBillableRate' => $this->showBillableRate,
|
'showBillableRate' => $this->showBillableRate,
|
||||||
|
'localization' => $this->localization,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,11 +75,32 @@ return [
|
|||||||
|
|
||||||
'url' => env('APP_URL', 'http://localhost'),
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Trusted Hosts
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Additional hostnames (besides the APP_URL host and its subdomains) that
|
||||||
|
| the application is allowed to respond on. This is needed for multi-host
|
||||||
|
| setups, e.g. reaching the instance over both a public domain and a
|
||||||
|
| Tailscale name. A request arriving on any host that is neither APP_URL
|
||||||
|
| (nor a subdomain of it) nor listed here is rejected, which prevents
|
||||||
|
| Host-header poisoning of password reset and other out-of-band links.
|
||||||
|
|
|
||||||
|
| See App\Http\Middleware\TrustHosts.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'trusted_hosts' => array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', (string) env('TRUSTED_HOSTS', ''))
|
||||||
|
))),
|
||||||
|
|
||||||
'asset_url' => env('ASSET_URL'),
|
'asset_url' => env('ASSET_URL'),
|
||||||
|
|
||||||
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
||||||
|
|
||||||
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
|
'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
|
||||||
|
|
||||||
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
||||||
|
|
||||||
@@ -137,6 +158,21 @@ return [
|
|||||||
|
|
||||||
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
|
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Rate Limiting
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The number of API requests allowed per minute, counted per user for
|
||||||
|
| authenticated requests and per IP address for guest requests. These
|
||||||
|
| limits are only enforced when the application runs in production.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'api_rate_limit_authenticated_per_minute' => (int) (env('API_RATE_LIMIT_AUTH_PER_MINUTE') ?: 200),
|
||||||
|
|
||||||
|
'api_rate_limit_guest_per_minute' => (int) (env('API_RATE_LIMIT_GUEST_PER_MINUTE') ?: 60),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Encryption Key
|
| Encryption Key
|
||||||
|
|||||||
34
config/import.php
Normal file
34
config/import.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Import payload limit
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Maximum length of the base64 encoded "data" field of an import request in
|
||||||
|
| bytes. Requests with a larger payload are rejected with a validation error.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'max_data_size' => (int) (env('IMPORT_MAX_DATA_SIZE') ?: 50 * 1024 * 1024),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ZIP extraction limits
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Limits applied to ZIP based importers before and during extraction to
|
||||||
|
| protect the instance against decompression bombs. The uncompressed size
|
||||||
|
| is the sum of all files in the archive in bytes.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
|
||||||
|
|
||||||
|
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
use function Laravel\Prompts\info as consoleInfo;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* PostgreSQL cannot build or drop an index concurrently inside a transaction.
|
||||||
|
* Keeping this migration non-transactional prevents long write locks in production.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $withinTransaction = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Tenant-scoped client pagination ordered by newest first; also covers the organization FK.
|
||||||
|
$this->runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)');
|
||||||
|
|
||||||
|
// Tenant-scoped project pagination ordered by newest first; also covers the organization FK.
|
||||||
|
$this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)');
|
||||||
|
// Speeds client relationship loads and the FK check when a client is deleted or its ID changes.
|
||||||
|
$this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)');
|
||||||
|
|
||||||
|
// Tenant-scoped task pagination ordered by newest first; also covers the organization FK.
|
||||||
|
$this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)');
|
||||||
|
// Speeds project task lists and the FK check when a project is deleted or its ID changes.
|
||||||
|
$this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)');
|
||||||
|
|
||||||
|
// Tenant-scoped tag pagination ordered by newest first; also covers the organization FK.
|
||||||
|
$this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)');
|
||||||
|
|
||||||
|
// Tenant-scoped report pagination ordered by newest first; also covers the organization FK.
|
||||||
|
$this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)');
|
||||||
|
|
||||||
|
// Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup.
|
||||||
|
$this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)');
|
||||||
|
// Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes.
|
||||||
|
$this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)');
|
||||||
|
|
||||||
|
// The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes.
|
||||||
|
$this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)');
|
||||||
|
$this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)');
|
||||||
|
|
||||||
|
// Supports organization deletion/current-team cleanup and the FK check on users.current_team_id.
|
||||||
|
$this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)');
|
||||||
|
|
||||||
|
// Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table.
|
||||||
|
$this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)');
|
||||||
|
|
||||||
|
// Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK.
|
||||||
|
$this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)');
|
||||||
|
// Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering.
|
||||||
|
$this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)');
|
||||||
|
// Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK.
|
||||||
|
$this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)');
|
||||||
|
// Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK.
|
||||||
|
$this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)');
|
||||||
|
// These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted.
|
||||||
|
$this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)');
|
||||||
|
$this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)');
|
||||||
|
$this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)');
|
||||||
|
// Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup.
|
||||||
|
$this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL');
|
||||||
|
// whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index.
|
||||||
|
$this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)');
|
||||||
|
|
||||||
|
// Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update.
|
||||||
|
$this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)');
|
||||||
|
$this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)');
|
||||||
|
|
||||||
|
// owner_id is already the leading column of oauth_clients_owner_id_owner_type_index.
|
||||||
|
$this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index');
|
||||||
|
// Public report lookup starts with the unique share_secret index; no query filters only by this boolean.
|
||||||
|
$this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$indexes = [
|
||||||
|
'clients_organization_created_id_index',
|
||||||
|
'projects_organization_created_id_index',
|
||||||
|
'projects_client_id_index',
|
||||||
|
'tasks_organization_created_id_index',
|
||||||
|
'tasks_project_id_index',
|
||||||
|
'tags_organization_created_id_index',
|
||||||
|
'reports_organization_created_id_index',
|
||||||
|
'members_organization_created_id_index',
|
||||||
|
'members_user_id_index',
|
||||||
|
'project_members_member_id_index',
|
||||||
|
'project_members_user_id_index',
|
||||||
|
'users_current_team_id_index',
|
||||||
|
'audits_created_at_index',
|
||||||
|
'time_entries_organization_start_id_index',
|
||||||
|
'time_entries_created_at_index',
|
||||||
|
'time_entries_user_organization_start_index',
|
||||||
|
'time_entries_member_start_index',
|
||||||
|
'time_entries_project_id_index',
|
||||||
|
'time_entries_task_id_index',
|
||||||
|
'time_entries_client_id_index',
|
||||||
|
'time_entries_active_member_index',
|
||||||
|
'time_entries_tags_gin_index',
|
||||||
|
'oauth_access_tokens_client_id_index',
|
||||||
|
'oauth_auth_codes_client_id_index',
|
||||||
|
];
|
||||||
|
|
||||||
|
$concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
|
||||||
|
foreach ($indexes as $index) {
|
||||||
|
$this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)');
|
||||||
|
$this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runIndexOperation(string $operation, string $index, string $statement): void
|
||||||
|
{
|
||||||
|
$indexState = $this->indexState($index);
|
||||||
|
|
||||||
|
if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) {
|
||||||
|
$this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operation === 'drop' && $indexState === null) {
|
||||||
|
$this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operation === 'create' && $indexState !== null) {
|
||||||
|
$this->writeProgress(sprintf(
|
||||||
|
'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding',
|
||||||
|
$index,
|
||||||
|
$indexState['valid'] ? 'true' : 'false',
|
||||||
|
$indexState['ready'] ? 'true' : 'false',
|
||||||
|
));
|
||||||
|
$this->executeIndexStatement(
|
||||||
|
'drop incomplete',
|
||||||
|
$index,
|
||||||
|
(DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->executeIndexStatement($operation, $index, $statement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function executeIndexStatement(string $operation, string $index, string $statement): void
|
||||||
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index));
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::statement($statement);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->writeProgress(sprintf(
|
||||||
|
'Failed to %s index [%s] after %.2f seconds: %s',
|
||||||
|
$operation,
|
||||||
|
$index,
|
||||||
|
microtime(true) - $startedAt,
|
||||||
|
$exception->getMessage(),
|
||||||
|
));
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeProgress(sprintf(
|
||||||
|
'Finished %s index [%s] in %.2f seconds',
|
||||||
|
$operation === 'create' ? 'creating' : 'dropping',
|
||||||
|
$index,
|
||||||
|
microtime(true) - $startedAt,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{valid: bool, ready: bool}|null
|
||||||
|
*/
|
||||||
|
private function indexState(string $index): ?array
|
||||||
|
{
|
||||||
|
$state = DB::selectOne(
|
||||||
|
<<<'SQL'
|
||||||
|
SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready
|
||||||
|
FROM pg_index
|
||||||
|
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||||
|
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
||||||
|
WHERE pg_namespace.nspname = current_schema()
|
||||||
|
AND pg_class.relname = ?
|
||||||
|
SQL,
|
||||||
|
[$index],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($state === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => (bool) $state->valid,
|
||||||
|
'ready' => (bool) $state->ready,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quoteIdentifier(string $identifier): string
|
||||||
|
{
|
||||||
|
return DB::connection()->getQueryGrammar()->wrap($identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeProgress(string $message): void
|
||||||
|
{
|
||||||
|
if (app()->runningUnitTests()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf('[%s] %s', date(DATE_ATOM), $message);
|
||||||
|
Log::info($message);
|
||||||
|
consoleInfo($message);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('send_time_entry_still_running_email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Placeholder users used to be created as a full copy of the user they were made from,
|
||||||
|
* which included the credentials and the account state of that user. A placeholder is a
|
||||||
|
* stand-in for a person in one organization, not an account, and the row shares the email
|
||||||
|
* address with the real account, so these values are removed from the placeholders that
|
||||||
|
* already exist. The organization a placeholder belongs to is recorded on its member row.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('users')
|
||||||
|
->where('is_placeholder', '=', true)
|
||||||
|
->where(function (Builder $builder): void {
|
||||||
|
$builder->whereNotNull('password')
|
||||||
|
->orWhereNotNull('remember_token')
|
||||||
|
->orWhereNotNull('two_factor_secret')
|
||||||
|
->orWhereNotNull('two_factor_recovery_codes')
|
||||||
|
->orWhereNotNull('two_factor_confirmed_at')
|
||||||
|
->orWhereNotNull('email_verified_at')
|
||||||
|
->orWhereNotNull('pending_email')
|
||||||
|
->orWhereNotNull('current_team_id')
|
||||||
|
->orWhereNotNull('profile_photo_path');
|
||||||
|
})
|
||||||
|
->update([
|
||||||
|
'password' => null,
|
||||||
|
'remember_token' => null,
|
||||||
|
'two_factor_secret' => null,
|
||||||
|
'two_factor_recovery_codes' => null,
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
|
'email_verified_at' => null,
|
||||||
|
'pending_email' => null,
|
||||||
|
'current_team_id' => null,
|
||||||
|
'profile_photo_path' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
ARG PHP_VERSION=8.3
|
ARG PHP_VERSION=8.3
|
||||||
ARG FRANKENPHP_VERSION=1.8
|
ARG FRANKENPHP_VERSION=1.11
|
||||||
ARG COMPOSER_VERSION=2.8
|
ARG COMPOSER_VERSION=2.8
|
||||||
ARG BUN_VERSION="latest"
|
|
||||||
ARG APP_ENV
|
ARG APP_ENV
|
||||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||||
|
|
||||||
@@ -16,13 +15,13 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build v2.10.0 \
|
xcaddy build \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
--with github.com/dunglas/caddy-cbrotli
|
--with github.com/dunglas/caddy-cbrotli
|
||||||
|
|
||||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
|
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||||
|
|
||||||
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
||||||
|
|
||||||
@@ -32,26 +31,28 @@ LABEL org.opencontainers.image.description="solidtime is a modern open source ti
|
|||||||
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL"
|
LABEL org.opencontainers.image.licenses="AGPL"
|
||||||
|
|
||||||
ARG WWWUSER=1000
|
ARG USER_ID=1000
|
||||||
ARG WWWGROUP=1000
|
ARG GROUP_ID=1000
|
||||||
ARG TZ=UTC
|
ARG TZ=UTC
|
||||||
ARG APP_DIR=/var/www/html
|
|
||||||
ARG APP_ENV
|
|
||||||
ARG APP_HOST
|
|
||||||
ARG DOCKER_FILES_BASE_PATH
|
ARG DOCKER_FILES_BASE_PATH
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
TERM=xterm-color \
|
TERM=xterm-color \
|
||||||
OCTANE_SERVER=frankenphp \
|
OCTANE_SERVER=frankenphp \
|
||||||
TZ=${TZ} \
|
TZ=${TZ} \
|
||||||
USER=octane \
|
LANG=C.UTF-8 \
|
||||||
ROOT=${APP_DIR} \
|
USER=laravel \
|
||||||
APP_ENV=${APP_ENV} \
|
ROOT=/var/www/html \
|
||||||
|
APP_ENV=production \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
COMPOSER_FUND=0 \
|
COMPOSER_FUND=0 \
|
||||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
COMPOSER_MAX_PARALLEL_HTTP=48 \
|
||||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
WITH_HORIZON=false \
|
||||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
WITH_SCHEDULER=false \
|
||||||
SERVER_NAME=${APP_HOST}
|
WITH_REVERB=false \
|
||||||
|
WITH_SSR=false
|
||||||
|
|
||||||
|
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
|
||||||
|
|
||||||
WORKDIR ${ROOT}
|
WORKDIR ${ROOT}
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
|
|||||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||||
&& echo ${TZ} > /etc/timezone
|
&& echo ${TZ} > /etc/timezone
|
||||||
|
|
||||||
|
RUN echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
||||||
|
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
||||||
|
|
||||||
RUN apt-get update; \
|
RUN apt-get update; \
|
||||||
apt-get upgrade -yqq; \
|
apt-get upgrade -yqq; \
|
||||||
apt-get install -yqq --no-install-recommends --show-progress \
|
apt-get install -yqq --no-install-recommends --show-progress \
|
||||||
@@ -68,40 +72,36 @@ RUN apt-get update; \
|
|||||||
wget \
|
wget \
|
||||||
vim \
|
vim \
|
||||||
git \
|
git \
|
||||||
|
unzip \
|
||||||
ncdu \
|
ncdu \
|
||||||
procps \
|
procps \
|
||||||
unzip \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
supervisor \
|
supervisor \
|
||||||
libsodium-dev \
|
libsodium-dev \
|
||||||
libbrotli-dev \
|
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
|
||||||
# Install PHP extensions (included with dunglas/frankenphp)
|
|
||||||
&& install-php-extensions \
|
&& install-php-extensions \
|
||||||
|
apcu \
|
||||||
bz2 \
|
bz2 \
|
||||||
pcntl \
|
pcntl \
|
||||||
mbstring \
|
mbstring \
|
||||||
bcmath \
|
bcmath \
|
||||||
sockets \
|
sockets \
|
||||||
pgsql \
|
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
opcache \
|
opcache \
|
||||||
exif \
|
exif \
|
||||||
pdo_mysql \
|
pdo_mysql \
|
||||||
zip \
|
zip \
|
||||||
uv \
|
uv \
|
||||||
vips \
|
|
||||||
intl \
|
intl \
|
||||||
gd \
|
gd \
|
||||||
redis \
|
redis \
|
||||||
rdkafka \
|
rdkafka \
|
||||||
memcached \
|
ffi \
|
||||||
igbinary \
|
|
||||||
ldap \
|
ldap \
|
||||||
&& apt-get -y autoremove \
|
&& apt-get -y autoremove \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& docker-php-source delete \
|
&& docker-php-source delete \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog
|
||||||
&& rm /var/log/lastlog /var/log/faillog
|
|
||||||
|
|
||||||
RUN arch="$(uname -m)" \
|
RUN arch="$(uname -m)" \
|
||||||
&& case "$arch" in \
|
&& case "$arch" in \
|
||||||
@@ -111,104 +111,64 @@ RUN arch="$(uname -m)" \
|
|||||||
x86) _cronic_fname='supercronic-linux-386' ;; \
|
x86) _cronic_fname='supercronic-linux-386' ;; \
|
||||||
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
||||||
esac \
|
esac \
|
||||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
|
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \
|
||||||
-O /usr/bin/supercronic \
|
-O /usr/bin/supercronic \
|
||||||
&& chmod +x /usr/bin/supercronic \
|
&& chmod +x /usr/bin/supercronic \
|
||||||
&& mkdir -p /etc/supercronic \
|
&& mkdir -p /etc/supercronic \
|
||||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
||||||
|
|
||||||
RUN userdel --remove --force www-data \
|
RUN userdel --remove --force www-data \
|
||||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
&& groupadd --force -g ${GROUP_ID} ${USER} \
|
||||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
|
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER}
|
||||||
&& setcap -r /usr/local/bin/frankenphp
|
|
||||||
|
|
||||||
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
|
|
||||||
&& chmod -R a+rw ${ROOT} /var/{log,run}
|
|
||||||
|
|
||||||
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
||||||
|
|
||||||
USER ${USER}
|
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-php.ini
|
||||||
|
#COPY --link composer.* ./
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
|
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
|
||||||
|
|
||||||
###########################################
|
|
||||||
|
|
||||||
#FROM base AS common
|
|
||||||
#
|
|
||||||
#USER ${USER}
|
|
||||||
#
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
|
||||||
#
|
|
||||||
#RUN composer install \
|
#RUN composer install \
|
||||||
# --no-dev \
|
# --no-dev \
|
||||||
# --no-interaction \
|
# --no-interaction \
|
||||||
# --no-autoloader \
|
# --no-autoloader \
|
||||||
# --no-ansi \
|
# --no-ansi \
|
||||||
# --no-scripts \
|
# --no-scripts \
|
||||||
|
# --no-progress \
|
||||||
# --audit
|
# --audit
|
||||||
|
|
||||||
###########################################
|
|
||||||
# Build frontend assets with Bun
|
|
||||||
###########################################
|
|
||||||
|
|
||||||
#FROM oven/bun:${BUN_VERSION} AS build
|
|
||||||
#
|
|
||||||
#ARG APP_ENV
|
|
||||||
#
|
|
||||||
#ENV ROOT=/var/www/html \
|
|
||||||
# APP_ENV=${APP_ENV} \
|
|
||||||
# NODE_ENV=${APP_ENV:-production}
|
|
||||||
#
|
|
||||||
#WORKDIR ${ROOT}
|
|
||||||
#
|
|
||||||
#COPY --link package.json bun.lock* ./
|
#COPY --link package.json bun.lock* ./
|
||||||
#
|
|
||||||
#RUN bun install --frozen-lockfile
|
#RUN bun install --frozen-lockfile
|
||||||
#
|
|
||||||
#COPY --link . .
|
|
||||||
#COPY --link --from=common ${ROOT}/vendor vendor
|
|
||||||
#
|
|
||||||
#RUN bun run build
|
|
||||||
|
|
||||||
###########################################
|
COPY --link . .
|
||||||
|
|
||||||
#FROM common AS runner
|
|
||||||
|
|
||||||
USER ${USER}
|
|
||||||
|
|
||||||
ENV WITH_HORIZON=false \
|
|
||||||
WITH_SCHEDULER=false \
|
|
||||||
WITH_REVERB=false
|
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
storage/framework/{sessions,views,cache,testing} \
|
storage/framework/{sessions,views,cache,testing} \
|
||||||
storage/logs \
|
storage/logs \
|
||||||
bootstrap/cache && chmod -R a+rw storage
|
bootstrap/cache \
|
||||||
|
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||||
|
|
||||||
#RUN composer install \
|
RUN composer dump-autoload \
|
||||||
# --classmap-authoritative \
|
--optimize \
|
||||||
# --no-interaction \
|
--apcu \
|
||||||
# --no-ansi \
|
--no-dev
|
||||||
# --no-dev \
|
|
||||||
# && composer clear-cache
|
|
||||||
|
|
||||||
RUN cat .env
|
#RUN bun run build
|
||||||
#RUN php artisan env
|
|
||||||
|
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
|
||||||
|
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
USER ${USER}
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
#EXPOSE 2019
|
||||||
|
#EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
ENTRYPOINT ["start-container"]
|
||||||
|
|
||||||
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ elif [ "${container_mode}" = "reverb" ]; then
|
|||||||
echo "Healthcheck failed."
|
echo "Healthcheck failed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
elif [ "${container_mode}" = "ssr" ]; then
|
||||||
|
if [ "$(supervisorctl status inertia-ssr-server:inertia-ssr-server_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Healthcheck failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
elif [ "${container_mode}" = "worker" ]; then
|
elif [ "${container_mode}" = "worker" ]; then
|
||||||
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
|
|
||||||
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
||||||
|
|
||||||
|
log {
|
||||||
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_https off
|
||||||
|
|
||||||
|
skip_install_trust
|
||||||
|
|
||||||
frankenphp {
|
frankenphp {
|
||||||
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
||||||
}
|
}
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
|
|
||||||
{$CADDY_SERVER_SERVER_NAME} {
|
{$CADDY_SERVER_SERVER_NAME} {
|
||||||
log {
|
log {
|
||||||
level WARN
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
|
||||||
format filter {
|
format filter {
|
||||||
wrap {$CADDY_SERVER_LOGGER}
|
wrap {$CADDY_SERVER_LOGGER}
|
||||||
@@ -60,7 +68,6 @@
|
|||||||
error @rejected 401
|
error @rejected 401
|
||||||
|
|
||||||
php_server {
|
php_server {
|
||||||
index frankenphp-worker.php
|
|
||||||
try_files {path} frankenphp-worker.php
|
try_files {path} frankenphp-worker.php
|
||||||
resolve_root_symlink
|
resolve_root_symlink
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,18 @@
|
|||||||
[program:octane]
|
[program:octane]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-host=0.0.0.0 --admin-port=2019 --log-level=WARN --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
priority = 1
|
priority = 1
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopwaitsecs = 30
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
environment = LARAVEL_OCTANE = "1"
|
environment = LARAVEL_OCTANE = "1"
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
stderr_logfile_maxbytes = 0
|
stderr_logfile_maxbytes = 0
|
||||||
|
|
||||||
[program:horizon]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan horizon
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 3
|
|
||||||
autostart = %(ENV_WITH_HORIZON)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
stopwaitsecs = 3600
|
|
||||||
|
|
||||||
[program:scheduler]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = supercronic -overlapping /etc/supercronic/laravel
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
|
|
||||||
[program:clear-scheduler-cache]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
|
||||||
autorestart = false
|
|
||||||
startsecs = 0
|
|
||||||
startretries = 1
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
|
|
||||||
[program:reverb]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 2
|
|
||||||
autostart = %(ENV_WITH_REVERB)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
minfds = 10000
|
|
||||||
|
|
||||||
[include]
|
[include]
|
||||||
files = /etc/supervisord.conf
|
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
post_max_size = 100M
|
post_max_size = 100M
|
||||||
upload_max_filesize = 100M
|
upload_max_filesize = 100M
|
||||||
expose_php = 0
|
expose_php = 0
|
||||||
realpath_cache_size = 16M
|
realpath_cache_size = 32M
|
||||||
realpath_cache_ttl = 360
|
realpath_cache_ttl = 720
|
||||||
|
memory_limit = 256M
|
||||||
max_input_time = 5
|
max_input_time = 5
|
||||||
register_argc_argv = 0
|
register_argc_argv = 0
|
||||||
date.timezone = ${TZ:-UTC}
|
date.timezone = ${TZ:-UTC}
|
||||||
@@ -11,18 +12,24 @@ date.timezone = ${TZ:-UTC}
|
|||||||
[Opcache]
|
[Opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.enable_cli = 1
|
opcache.enable_cli = 1
|
||||||
opcache.memory_consumption = 256M
|
opcache.memory_consumption = 256
|
||||||
opcache.use_cwd = 0
|
opcache.use_cwd = 0
|
||||||
|
opcache.save_comments = 1
|
||||||
opcache.max_file_size = 0
|
opcache.max_file_size = 0
|
||||||
opcache.max_accelerated_files = 32531
|
opcache.max_accelerated_files = 32531
|
||||||
opcache.validate_timestamps = 0
|
opcache.validate_timestamps = 0
|
||||||
opcache.file_update_protection = 0
|
opcache.file_update_protection = 0
|
||||||
opcache.interned_strings_buffer = 16
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.enable_file_override = 1
|
||||||
|
opcache.file_cache_consistency_checks = 0
|
||||||
|
opcache.file_cache = /tmp/opcache-file-cache
|
||||||
|
|
||||||
[JIT]
|
[JIT]
|
||||||
opcache.jit_buffer_size = 128M
|
opcache.jit_buffer_size = 128M
|
||||||
opcache.jit = function
|
opcache.jit = tracing
|
||||||
opcache.jit_prof_threshold = 0.001
|
opcache.jit_hot_loop = 16
|
||||||
|
opcache.jit_hot_func = 32
|
||||||
|
opcache.jit_hot_return = 4
|
||||||
opcache.jit_max_root_traces = 2048
|
opcache.jit_max_root_traces = 2048
|
||||||
opcache.jit_max_side_traces = 256
|
opcache.jit_max_side_traces = 256
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon = true
|
nodaemon = true
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
logfile = /var/log/supervisor/supervisord.log
|
|
||||||
pidfile = /var/run/supervisord.pid
|
|
||||||
|
|
||||||
[supervisorctl]
|
[supervisorctl]
|
||||||
|
|
||||||
@@ -10,4 +8,4 @@ pidfile = /var/run/supervisord.pid
|
|||||||
port = 127.0.0.1:9001
|
port = 127.0.0.1:9001
|
||||||
|
|
||||||
[rpcinterface:supervisor]
|
[rpcinterface:supervisor]
|
||||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan horizon
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[program:inertia-ssr-server]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = true
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = /dev/stdout
|
||||||
|
stdout_logfile_maxbytes = 0
|
||||||
|
stderr_logfile = /dev/stderr
|
||||||
|
stderr_logfile_maxbytes = 0
|
||||||
|
|
||||||
|
[include]
|
||||||
|
files = /etc/supervisord.conf
|
||||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan reverb:start
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ command = supercronic -overlapping /etc/supercronic/laravel
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
69
docker/prod/deployment/supervisord.services.conf
Normal file
69
docker/prod/deployment/supervisord.services.conf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[program:horizon]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan horizon
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 4
|
||||||
|
autostart = %(ENV_WITH_HORIZON)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
stopwaitsecs = 3600
|
||||||
|
|
||||||
|
[program:scheduler]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = supercronic -overlapping /etc/supercronic/laravel
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
|
||||||
|
[program:clear-scheduler-cache]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
|
autorestart = false
|
||||||
|
startsecs = 0
|
||||||
|
startretries = 1
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
|
||||||
|
[program:reverb]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 3
|
||||||
|
autostart = %(ENV_WITH_REVERB)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
minfds = 10000
|
||||||
|
|
||||||
|
[program:inertia-ssr-server]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 2
|
||||||
|
autostart = %(ENV_WITH_SSR)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
@@ -4,6 +4,8 @@ command = %(ENV_WORKER_COMMAND)s
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
createProjectViaApi,
|
createProjectViaApi,
|
||||||
createPublicProjectViaApi,
|
createPublicProjectViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import { getTableRowNames } from './utils/table';
|
import { clearTableState, getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToClientsOverview(page: Page) {
|
async function goToClientsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
@@ -210,18 +210,12 @@ test('test that client context menu delete deletes the client', async ({ page, c
|
|||||||
// Sorting Tests
|
// 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 }) => {
|
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
|
||||||
await createClientViaApi(ctx, { name: 'AAA SortClient' });
|
await createClientViaApi(ctx, { name: 'AAA SortClient' });
|
||||||
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
|
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
await goToClientsOverview(page);
|
||||||
await clearClientTableState(page);
|
await clearTableState(page, 'client-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('client_table');
|
const table = page.getByTestId('client_table');
|
||||||
@@ -253,7 +247,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
|
|||||||
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
|
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
await goToClientsOverview(page);
|
||||||
await clearClientTableState(page);
|
await clearTableState(page, 'client-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('client_table');
|
const table = page.getByTestId('client_table');
|
||||||
@@ -274,7 +268,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
|
|||||||
|
|
||||||
test('test that client sort state persists after page reload', async ({ page }) => {
|
test('test that client sort state persists after page reload', async ({ page }) => {
|
||||||
await goToClientsOverview(page);
|
await goToClientsOverview(page);
|
||||||
await clearClientTableState(page);
|
await clearTableState(page, 'client-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('client_table');
|
const table = page.getByTestId('client_table');
|
||||||
@@ -397,7 +391,7 @@ test.describe('Clients Pagination', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
await goToClientsOverview(page);
|
||||||
await clearClientTableState(page);
|
await clearTableState(page, 'client-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Default sort is name asc; first 15 clients (00–14) on page 1.
|
// Default sort is name asc; first 15 clients (00–14) on page 1.
|
||||||
@@ -450,7 +444,7 @@ test.describe('Clients Pagination', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
await goToClientsOverview(page);
|
||||||
await clearClientTableState(page);
|
await clearTableState(page, 'client-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toBeVisible();
|
await expect(page.getByTestId('client_table')).toBeVisible();
|
||||||
@@ -470,7 +464,7 @@ test.describe('Clients Pagination', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
await goToClientsOverview(page);
|
||||||
await clearClientTableState(page);
|
await clearTableState(page, 'client-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
32
e2e/invitation-registration-prefill.spec.ts
Normal file
32
e2e/invitation-registration-prefill.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import { getInvitationAcceptUrl } from './utils/mailpit';
|
||||||
|
|
||||||
|
test('prefills the registration email after accepting an invitation', async ({ page, browser }) => {
|
||||||
|
const memberId = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
const memberEmail = `prefill-${memberId}@invitation.test`;
|
||||||
|
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await page.getByPlaceholder('Member Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inviteeContext = await browser.newContext();
|
||||||
|
const inviteePage = await inviteeContext.newPage();
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||||
|
await inviteePage.goto(acceptUrl);
|
||||||
|
await inviteePage.waitForURL(/\/register$/);
|
||||||
|
|
||||||
|
await expect(inviteePage.getByLabel('Email')).toHaveValue(memberEmail);
|
||||||
|
|
||||||
|
await inviteeContext.close();
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
updateMemberBillableRateViaApi,
|
updateMemberBillableRateViaApi,
|
||||||
updateOrganizationSettingViaApi,
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import { getTableRowNames } from './utils/table';
|
import { clearTableState, getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
// Tests that invite + accept members need more time
|
// Tests that invite + accept members need more time
|
||||||
test.describe.configure({ timeout: 45000 });
|
test.describe.configure({ timeout: 45000 });
|
||||||
@@ -779,20 +779,18 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
|
|||||||
// Sorting Tests
|
// Sorting Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|
||||||
// Helper to clear localStorage before tests that check sorting
|
|
||||||
async function clearMemberTableState(page: Page) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.removeItem('member-table-state');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
|
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
|
||||||
// Create two placeholder members with names that sort predictably around "John Doe"
|
// Create two placeholder members with names that sort predictably around "John Doe".
|
||||||
|
// Seeded alphabetically a second apart: created_at only has second precision and
|
||||||
|
// same-second rows fall back to a random UUID order. The spacing is what makes the
|
||||||
|
// API order (created_at desc: ZZZ, AAA, John) deterministic, so the tie-break
|
||||||
|
// assertions below are testing the tie-break rather than a coin flip.
|
||||||
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
|
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
|
||||||
|
await page.waitForTimeout(1100);
|
||||||
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
|
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
|
||||||
|
|
||||||
await goToMembersPage(page);
|
await goToMembersPage(page);
|
||||||
await clearMemberTableState(page);
|
await clearTableState(page, 'member-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('member_table');
|
const table = page.getByTestId('member_table');
|
||||||
@@ -814,20 +812,24 @@ test('test that sorting members by name, role, and status works', async ({ page,
|
|||||||
const ownerIdx = names.indexOf('John Doe');
|
const ownerIdx = names.indexOf('John Doe');
|
||||||
const placeholderIdx = names.indexOf('AAA SortFirst');
|
const placeholderIdx = names.indexOf('AAA SortFirst');
|
||||||
expect(ownerIdx).toBeLessThan(placeholderIdx);
|
expect(ownerIdx).toBeLessThan(placeholderIdx);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
||||||
|
|
||||||
await roleHeader.click(); // desc: Placeholder first
|
await roleHeader.click(); // desc: Placeholder first
|
||||||
names = await getTableRowNames(table);
|
names = await getTableRowNames(table);
|
||||||
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
||||||
|
|
||||||
// -- Status sorting --
|
// -- Status sorting --
|
||||||
const statusHeader = table.getByText('Status').first();
|
const statusHeader = table.getByText('Status').first();
|
||||||
await statusHeader.click(); // asc: Active(0) < Inactive(1)
|
await statusHeader.click(); // asc: Active(0) < Inactive(1)
|
||||||
names = await getTableRowNames(table);
|
names = await getTableRowNames(table);
|
||||||
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
||||||
|
|
||||||
await statusHeader.click(); // desc: Inactive first
|
await statusHeader.click(); // desc: Inactive first
|
||||||
names = await getTableRowNames(table);
|
names = await getTableRowNames(table);
|
||||||
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
||||||
|
|
||||||
// -- Email: just verify sort indicator appears --
|
// -- Email: just verify sort indicator appears --
|
||||||
const emailHeader = table.getByText('Email').first();
|
const emailHeader = table.getByText('Email').first();
|
||||||
@@ -837,7 +839,7 @@ test('test that sorting members by name, role, and status works', async ({ page,
|
|||||||
|
|
||||||
test('test that member sort state persists after page reload', async ({ page }) => {
|
test('test that member sort state persists after page reload', async ({ page }) => {
|
||||||
await goToMembersPage(page);
|
await goToMembersPage(page);
|
||||||
await clearMemberTableState(page);
|
await clearTableState(page, 'member-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('member_table');
|
const table = page.getByTestId('member_table');
|
||||||
@@ -875,7 +877,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
|
|||||||
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
|
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
|
||||||
|
|
||||||
await goToMembersPage(page);
|
await goToMembersPage(page);
|
||||||
await clearMemberTableState(page);
|
await clearTableState(page, 'member-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('member_table');
|
const table = page.getByTestId('member_table');
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ function profileInformationForm(page: Page) {
|
|||||||
.locator('xpath=ancestor::*[descendant::form][1]');
|
.locator('xpath=ancestor::*[descendant::form][1]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notificationSettingsForm(page: Page) {
|
||||||
|
return page
|
||||||
|
.getByRole('heading', { name: 'Notifications', exact: true })
|
||||||
|
.locator('xpath=ancestor::*[descendant::form][1]');
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProfileForm(page: Page): Promise<void> {
|
async function saveProfileForm(page: Page): Promise<void> {
|
||||||
const form = profileInformationForm(page);
|
const form = profileInformationForm(page);
|
||||||
await form.getByRole('button', { name: 'Save' }).click();
|
await form.getByRole('button', { name: 'Save' }).click();
|
||||||
@@ -50,6 +56,22 @@ test('week-start change persists across reload', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('still-running email notification setting persists across reload', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const form = notificationSettingsForm(page);
|
||||||
|
const checkbox = form.getByLabel('Still-running time entry reminders');
|
||||||
|
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
await checkbox.uncheck();
|
||||||
|
await form.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
|
||||||
|
).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
|
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -13,18 +13,12 @@ import {
|
|||||||
archiveProjectViaApi,
|
archiveProjectViaApi,
|
||||||
updateOrganizationSettingViaApi,
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
import { clearTableState, getSeededRowOrder } from './utils/table';
|
||||||
|
|
||||||
async function goToProjectsOverview(page: Page) {
|
async function goToProjectsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to clear localStorage before tests that check persistence
|
|
||||||
async function clearProjectTableState(page: Page) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.removeItem('project-table-state');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new project via modal
|
// Create new project via modal
|
||||||
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
@@ -84,7 +78,7 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
|
|||||||
await createProjectViaApi(ctx, { name: newProjectName });
|
await createProjectViaApi(ctx, { name: newProjectName });
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
@@ -480,7 +474,7 @@ test('test that sorting projects by all columns works', async ({ page, ctx }) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||||
await expect(page.getByText('AAA Project')).toBeVisible();
|
await expect(page.getByText('AAA Project')).toBeVisible();
|
||||||
@@ -609,7 +603,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
|
|||||||
await createProjectViaApi(ctx, { name: newProjectName });
|
await createProjectViaApi(ctx, { name: newProjectName });
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
@@ -640,7 +634,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
|
|||||||
|
|
||||||
test('test that filter state persists after page reload', async ({ page }) => {
|
test('test that filter state persists after page reload', async ({ page }) => {
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Apply Active status filter
|
// Apply Active status filter
|
||||||
@@ -656,9 +650,96 @@ test('test that filter state persists after page reload', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that projects without a client or estimate are ordered by name at the bottom', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Seeded a second apart: created_at only has second precision and same-second rows
|
||||||
|
// fall back to a random UUID order. The spacing makes the API order of the clientless
|
||||||
|
// rows (created_at desc: ZZZ, AAA) deterministic and different from the alphabetical
|
||||||
|
// order the name tie-break should produce.
|
||||||
|
await createProjectViaApi(ctx, { name: 'AAA Tiebreak Project' });
|
||||||
|
await page.waitForTimeout(1100);
|
||||||
|
await createProjectViaApi(ctx, { name: 'ZZZ Tiebreak Project' });
|
||||||
|
|
||||||
|
const clientAardvark = await createClientViaApi(ctx, { name: 'Aardvark Co' });
|
||||||
|
const clientZulu = await createClientViaApi(ctx, { name: 'Zulu Co' });
|
||||||
|
const projectM = await createProjectViaApi(ctx, {
|
||||||
|
name: 'MMM Tiebreak Project',
|
||||||
|
client_id: clientAardvark.id,
|
||||||
|
estimated_time: 36000, // 10h, 1h tracked below = 10%
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: projectM.id });
|
||||||
|
const projectN = await createProjectViaApi(ctx, {
|
||||||
|
name: 'NNN Tiebreak Project',
|
||||||
|
client_id: clientZulu.id,
|
||||||
|
estimated_time: 14400, // 4h, 2h tracked below = 50%
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: projectN.id });
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearTableState(page, 'project-table-state');
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('project_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
const seeded = [
|
||||||
|
'AAA Tiebreak Project',
|
||||||
|
'MMM Tiebreak Project',
|
||||||
|
'NNN Tiebreak Project',
|
||||||
|
'ZZZ Tiebreak Project',
|
||||||
|
];
|
||||||
|
const getOrder = () => getSeededRowOrder(table, seeded);
|
||||||
|
|
||||||
|
// -- Client: empty rows last in both directions, alphabetical among themselves --
|
||||||
|
const clientHeader = table.locator('.select-none', { hasText: 'Client' }).first();
|
||||||
|
await clientHeader.click();
|
||||||
|
await expect
|
||||||
|
.poll(getOrder)
|
||||||
|
.toEqual([
|
||||||
|
'MMM Tiebreak Project',
|
||||||
|
'NNN Tiebreak Project',
|
||||||
|
'AAA Tiebreak Project',
|
||||||
|
'ZZZ Tiebreak Project',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await clientHeader.click();
|
||||||
|
await expect
|
||||||
|
.poll(getOrder)
|
||||||
|
.toEqual([
|
||||||
|
'NNN Tiebreak Project',
|
||||||
|
'MMM Tiebreak Project',
|
||||||
|
'AAA Tiebreak Project',
|
||||||
|
'ZZZ Tiebreak Project',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// -- Progress: same, and the first click sorts highest first --
|
||||||
|
const progressHeader = table.locator('.select-none', { hasText: 'Progress' }).first();
|
||||||
|
await progressHeader.click();
|
||||||
|
await expect
|
||||||
|
.poll(getOrder)
|
||||||
|
.toEqual([
|
||||||
|
'NNN Tiebreak Project',
|
||||||
|
'MMM Tiebreak Project',
|
||||||
|
'AAA Tiebreak Project',
|
||||||
|
'ZZZ Tiebreak Project',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await progressHeader.click();
|
||||||
|
await expect
|
||||||
|
.poll(getOrder)
|
||||||
|
.toEqual([
|
||||||
|
'MMM Tiebreak Project',
|
||||||
|
'NNN Tiebreak Project',
|
||||||
|
'AAA Tiebreak Project',
|
||||||
|
'ZZZ Tiebreak Project',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('test that sort state persists after page reload', async ({ page }) => {
|
test('test that sort state persists after page reload', async ({ page }) => {
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Click on Name header twice to sort descending
|
// Click on Name header twice to sort descending
|
||||||
@@ -1114,7 +1195,7 @@ test.describe('Projects Pagination', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Default sort is name asc; first 15 projects (00–14) should be on page 1.
|
// Default sort is name asc; first 15 projects (00–14) should be on page 1.
|
||||||
@@ -1168,7 +1249,7 @@ test.describe('Projects Pagination', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||||
@@ -1185,7 +1266,7 @@ test.describe('Projects Pagination', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
||||||
@@ -1207,3 +1288,36 @@ test.describe('Projects Pagination', () => {
|
|||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that searching projects by name works', async ({ page, ctx }) => {
|
||||||
|
const suffix = Math.floor(1 + Math.random() * 10000);
|
||||||
|
const matchingProjectName = 'Searchable Project ' + suffix;
|
||||||
|
const otherProjectName = 'Unrelated Work ' + suffix;
|
||||||
|
await createProjectViaApi(ctx, { name: matchingProjectName });
|
||||||
|
await createProjectViaApi(ctx, { name: otherProjectName });
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearTableState(page, 'project-table-state');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(otherProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
|
||||||
|
|
||||||
|
// Searching is case insensitive and matches part of the name
|
||||||
|
await searchInput.fill('SEARCHABLE');
|
||||||
|
await expect(page.getByText(matchingProjectName)).toBeVisible();
|
||||||
|
await expect(page.getByText(otherProjectName)).not.toBeVisible();
|
||||||
|
|
||||||
|
// A term that matches nothing empties the table
|
||||||
|
await searchInput.fill('no project has this name');
|
||||||
|
await expect(page.getByText(matchingProjectName)).not.toBeVisible();
|
||||||
|
await expect(page.getByText(otherProjectName)).not.toBeVisible();
|
||||||
|
await expect(page.getByText('No matching projects')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Clearing the search restores both projects
|
||||||
|
await searchInput.fill('');
|
||||||
|
await expect(page.getByText(matchingProjectName)).toBeVisible();
|
||||||
|
await expect(page.getByText(otherProjectName)).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
createTimeEntryWithTagViaApi,
|
createTimeEntryWithTagViaApi,
|
||||||
createTimeEntryWithBillableStatusViaApi,
|
createTimeEntryWithBillableStatusViaApi,
|
||||||
createBareTimeEntryViaApi,
|
createBareTimeEntryViaApi,
|
||||||
|
createTimeEntryOnDateViaApi,
|
||||||
createPublicProjectViaApi,
|
createPublicProjectViaApi,
|
||||||
updateOrganizationSettingViaApi,
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
@@ -841,6 +842,127 @@ test('test that setting group by to current sub group triggers sub group fallbac
|
|||||||
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
|
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that group by date groups the report by day and formats the date labels with organization settings', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
|
||||||
|
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: 'Entry for group by date',
|
||||||
|
duration: '1h',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to reporting page
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||||
|
|
||||||
|
// Find the "Group by" selects within the reporting table
|
||||||
|
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
|
||||||
|
|
||||||
|
// Default state: group=Project
|
||||||
|
await groupBySelects.filter({ hasText: 'Project' }).first().click();
|
||||||
|
|
||||||
|
const [aggregateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/aggregate') &&
|
||||||
|
response.url().includes('group=day') &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('option', { name: 'Date', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the API request contains the correct group parameter
|
||||||
|
const requestUrl = new URL(aggregateResponse.url());
|
||||||
|
expect(requestUrl.searchParams.get('group')).toBe('day');
|
||||||
|
|
||||||
|
// The row label is rendered in the organization date format (D.M.YYYY)
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('reporting_view').getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that group by week requests week grouping and does not leak the raw group key', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: 'Entry for group by week',
|
||||||
|
duration: '1h',
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||||
|
|
||||||
|
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
|
||||||
|
await groupBySelects.filter({ hasText: 'Project' }).first().click();
|
||||||
|
|
||||||
|
const [aggregateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/aggregate') &&
|
||||||
|
response.url().includes('group=week') &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('option', { name: 'Week', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const requestUrl = new URL(aggregateResponse.url());
|
||||||
|
expect(requestUrl.searchParams.get('group')).toBe('week');
|
||||||
|
|
||||||
|
// The raw group key is the first day of the week and must not leak through.
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that group by week labels a week spanning new year with a range crossing the year', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { date_format: 'slash-separated-dd-mm-yyyy' });
|
||||||
|
|
||||||
|
for (const day of ['2025-12-22', '2025-12-29', '2026-01-05']) {
|
||||||
|
await createTimeEntryOnDateViaApi(ctx, {
|
||||||
|
date: new Date(`${day}T09:00:00Z`),
|
||||||
|
duration: '1h',
|
||||||
|
description: `Entry for ${day}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reporting page keeps its range in session storage, so seed a range spanning new year
|
||||||
|
// rather than driving the date picker.
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.sessionStorage.setItem('reporting-start-date', '2025-12-15');
|
||||||
|
window.sessionStorage.setItem('reporting-end-date', '2026-01-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||||
|
|
||||||
|
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
|
||||||
|
await groupBySelects.filter({ hasText: 'Project' }).first().click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/aggregate') &&
|
||||||
|
response.url().includes('group=week') &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('option', { name: 'Week', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reportingView = page.getByTestId('reporting_view');
|
||||||
|
await expect(reportingView.getByText('22/12/2025 - 28/12/2025', { exact: true })).toBeVisible();
|
||||||
|
await expect(reportingView.getByText('29/12/2025 - 04/01/2026', { exact: true })).toBeVisible();
|
||||||
|
await expect(reportingView.getByText('05/01/2026 - 11/01/2026', { exact: true })).toBeVisible();
|
||||||
|
await expect(reportingView.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Export Tests
|
// Export Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createTimeEntryWithBillableStatusViaApi,
|
createTimeEntryWithBillableStatusViaApi,
|
||||||
createTagViaApi,
|
createTagViaApi,
|
||||||
createReportViaApi,
|
createReportViaApi,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import {
|
import {
|
||||||
goToReporting,
|
goToReporting,
|
||||||
@@ -68,6 +69,42 @@ test('test that saving a report creates a shared report and its shareable link s
|
|||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that a shared report grouped by date shows date labels formatted by the organization setting', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const reportName = 'DateGroupReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: 'Entry for date grouping',
|
||||||
|
duration: '1h',
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
|
||||||
|
// Switch the grouping to "Date"
|
||||||
|
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
|
||||||
|
await groupBySelects.filter({ hasText: 'Project' }).first().click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/aggregate') &&
|
||||||
|
response.url().includes('group=day') &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('option', { name: 'Date', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
|
// Verify row labels are formatted correctly
|
||||||
|
await page.goto(shareableLink);
|
||||||
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
await expect(page.getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('test that shared report with invalid secret shows no data', async ({ page }) => {
|
test('test that shared report with invalid secret shows no data', async ({ page }) => {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
|
||||||
await expect(page.getByText('No time entries found').first()).toBeVisible();
|
await expect(page.getByText('No time entries found').first()).toBeVisible();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { createTagViaApi } from './utils/api';
|
import { createTagViaApi } from './utils/api';
|
||||||
import { getTableRowNames } from './utils/table';
|
import { clearTableState, getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToTagsOverview(page: Page) {
|
async function goToTagsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
|
||||||
@@ -147,18 +147,12 @@ test('test that tag context menu delete deletes the tag', async ({ page, ctx })
|
|||||||
// Sorting Tests
|
// Sorting Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|
||||||
async function clearTagTableState(page: Page) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.removeItem('tag-table-state');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('test that sorting tags by name works', async ({ page, ctx }) => {
|
test('test that sorting tags by name works', async ({ page, ctx }) => {
|
||||||
await createTagViaApi(ctx, { name: 'AAA SortTag' });
|
await createTagViaApi(ctx, { name: 'AAA SortTag' });
|
||||||
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
|
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
|
||||||
|
|
||||||
await goToTagsOverview(page);
|
await goToTagsOverview(page);
|
||||||
await clearTagTableState(page);
|
await clearTableState(page, 'tag-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('tag_table');
|
const table = page.getByTestId('tag_table');
|
||||||
@@ -176,7 +170,7 @@ test('test that sorting tags by name works', async ({ page, ctx }) => {
|
|||||||
|
|
||||||
test('test that tag sort state persists after page reload', async ({ page }) => {
|
test('test that tag sort state persists after page reload', async ({ page }) => {
|
||||||
await goToTagsOverview(page);
|
await goToTagsOverview(page);
|
||||||
await clearTagTableState(page);
|
await clearTableState(page, 'tag-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const table = page.getByTestId('tag_table');
|
const table = page.getByTestId('tag_table');
|
||||||
|
|||||||
@@ -7,13 +7,56 @@ import {
|
|||||||
createPublicProjectViaApi,
|
createPublicProjectViaApi,
|
||||||
createTaskViaApi,
|
createTaskViaApi,
|
||||||
createClientViaApi,
|
createClientViaApi,
|
||||||
|
createTimeEntryViaApi,
|
||||||
updateOrganizationSettingViaApi,
|
updateOrganizationSettingViaApi,
|
||||||
|
type TestContext,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
import { clearTableState, getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToProjectsOverview(page: Page) {
|
async function goToProjectsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createSortableTasks(ctx: TestContext) {
|
||||||
|
const project = await createProjectViaApi(ctx, { name: 'Task Sorting Project' });
|
||||||
|
const taskA = await createTaskViaApi(ctx, {
|
||||||
|
name: 'AAA Sorting Task',
|
||||||
|
project_id: project.id,
|
||||||
|
estimated_time: 36000,
|
||||||
|
});
|
||||||
|
const taskB = await createTaskViaApi(ctx, {
|
||||||
|
name: 'BBB Sorting Task',
|
||||||
|
project_id: project.id,
|
||||||
|
estimated_time: 14400,
|
||||||
|
});
|
||||||
|
const taskC = await createTaskViaApi(ctx, {
|
||||||
|
name: 'CCC Sorting Task',
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(taskA.estimated_time).toBe(36000);
|
||||||
|
expect(taskB.estimated_time).toBe(14400);
|
||||||
|
expect(taskC.estimated_time).toBeNull();
|
||||||
|
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: taskA.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
duration: '2h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: taskB.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
duration: '3h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: taskC.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { project, taskA, taskB, taskC };
|
||||||
|
}
|
||||||
|
|
||||||
test('test that creating and deleting a new task in a new project works', async ({ page }) => {
|
test('test that creating and deleting a new task in a new project works', async ({ page }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
@@ -301,6 +344,59 @@ test('test that creating a new project from the task create modal project dropdo
|
|||||||
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
|
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that sorting tasks by name, total time and progress works', async ({ page, ctx }) => {
|
||||||
|
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearTableState(page, 'task-table-state');
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
|
||||||
|
const table = page.getByTestId('task_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// This project contains only the seeded tasks, so assert the complete order.
|
||||||
|
const expectOrder = async (expected: string[]) => {
|
||||||
|
await expect.poll(() => getTableRowNames(table)).toEqual(expected);
|
||||||
|
};
|
||||||
|
const clickHeader = async (headerText: string) => {
|
||||||
|
await table.getByText(headerText).first().click();
|
||||||
|
};
|
||||||
|
|
||||||
|
await expectOrder([taskA.name, taskB.name, taskC.name]);
|
||||||
|
await clickHeader('Task Name');
|
||||||
|
await expectOrder([taskC.name, taskB.name, taskA.name]);
|
||||||
|
await clickHeader('Task Name');
|
||||||
|
await expectOrder([taskA.name, taskB.name, taskC.name]);
|
||||||
|
|
||||||
|
await clickHeader('Total Time');
|
||||||
|
await expectOrder([taskC.name, taskB.name, taskA.name]);
|
||||||
|
await clickHeader('Total Time');
|
||||||
|
await expectOrder([taskA.name, taskB.name, taskC.name]);
|
||||||
|
|
||||||
|
await clickHeader('Progress');
|
||||||
|
await expectOrder([taskB.name, taskA.name, taskC.name]);
|
||||||
|
await clickHeader('Progress');
|
||||||
|
await expectOrder([taskA.name, taskB.name, taskC.name]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that task sort state persists after page reload', async ({ page, ctx }) => {
|
||||||
|
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearTableState(page, 'task-table-state');
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
|
||||||
|
const table = page.getByTestId('task_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
await table.getByText('Progress').first().click();
|
||||||
|
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the persisted row order, not just the sort indicator.
|
||||||
|
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
89
e2e/timezone-mismatch.spec.ts
Normal file
89
e2e/timezone-mismatch.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { test, expect } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import type { Page, TestContext } from '../playwright/fixtures';
|
||||||
|
import { getCurrentUserViaApi, updateUserProfileViaApi } from './utils/api';
|
||||||
|
|
||||||
|
const BROWSER_TIMEZONE = 'Europe/Vienna';
|
||||||
|
const MISMATCHED_TIMEZONE = 'America/New_York';
|
||||||
|
|
||||||
|
test.use({ timezoneId: BROWSER_TIMEZONE });
|
||||||
|
|
||||||
|
function mismatchModal(page: Page) {
|
||||||
|
return page.getByRole('dialog').filter({ hasText: 'Timezone mismatch detected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPageWithTimezoneMismatch(page: Page, ctx: TestContext) {
|
||||||
|
await updateUserProfileViaApi(ctx, { timezone: MISMATCHED_TIMEZONE });
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(mismatchModal(page)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('timezone mismatch modal saves the device timezone through the users API', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
await openPageWithTimezoneMismatch(page, ctx);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/api/v1/users/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(),
|
||||||
|
]);
|
||||||
|
await expect(mismatchModal(page)).toBeHidden();
|
||||||
|
|
||||||
|
const user = await getCurrentUserViaApi(ctx);
|
||||||
|
expect(user.timezone).toBe(BROWSER_TIMEZONE);
|
||||||
|
|
||||||
|
// After the automatic reload the timezones match again, so the modal stays gone.
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(mismatchModal(page)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timezone mismatch modal does not open for a different timezone with the same time', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Berlin and Vienna share the same offset and DST rules, so the times match.
|
||||||
|
await updateUserProfileViaApi(ctx, { timezone: 'Europe/Berlin' });
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
|
||||||
|
// Wait until the dashboard is rendered so the modal had its chance to mount.
|
||||||
|
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||||
|
await expect(mismatchModal(page)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timezone mismatch modal stays open when the update fails', async ({ page, ctx }) => {
|
||||||
|
await openPageWithTimezoneMismatch(page, ctx);
|
||||||
|
|
||||||
|
await page.route('**/api/v1/users/*', (route) => {
|
||||||
|
if (route.request().method() === 'PUT') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ message: 'Server error' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/api/v1/users/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 500
|
||||||
|
),
|
||||||
|
mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(mismatchModal(page)).toBeVisible();
|
||||||
|
await expect(page.getByText('Failed to update profile')).toBeVisible();
|
||||||
|
|
||||||
|
const user = await getCurrentUserViaApi(ctx);
|
||||||
|
expect(user.timezone).toBe(MISMATCHED_TIMEZONE);
|
||||||
|
});
|
||||||
@@ -357,7 +357,7 @@ export async function createProjectWithClientViaApi(
|
|||||||
|
|
||||||
export async function createTaskViaApi(
|
export async function createTaskViaApi(
|
||||||
ctx: TestContext,
|
ctx: TestContext,
|
||||||
data: { name: string; project_id: string }
|
data: { name: string; project_id: string; estimated_time?: number }
|
||||||
) {
|
) {
|
||||||
const response = await ctx.request.post(
|
const response = await ctx.request.post(
|
||||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,
|
||||||
@@ -365,12 +365,20 @@ export async function createTaskViaApi(
|
|||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
project_id: data.project_id,
|
project_id: data.project_id,
|
||||||
|
...(data.estimated_time !== undefined
|
||||||
|
? { estimated_time: data.estimated_time }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
expect(response.status()).toBe(201);
|
expect(response.status()).toBe(201);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
return body.data as { id: string; name: string; project_id: string };
|
return body.data as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
project_id: string;
|
||||||
|
estimated_time: number | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {
|
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Locator } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the first cell's text content from each row in a table.
|
* Extract the first cell's text content from each row in a table.
|
||||||
@@ -14,3 +14,22 @@ export async function getTableRowNames(table: Locator): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visual order of the given seeded names within the table, ignoring any other rows.
|
||||||
|
*/
|
||||||
|
export async function getSeededRowOrder(table: Locator, seeded: string[]): Promise<string[]> {
|
||||||
|
const rowNames = await getTableRowNames(table);
|
||||||
|
return rowNames
|
||||||
|
.map((rowName) => seeded.find((name) => rowName.includes(name)))
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a table's persisted sort/filter state so a test starts from the defaults.
|
||||||
|
*/
|
||||||
|
export async function clearTableState(page: Page, key: string) {
|
||||||
|
await page.evaluate((storageKey) => {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}, key);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"Billing": {
|
"Billing": {
|
||||||
"repository": "solidtime-io/extension-billing",
|
"repository": "solidtime-io/extension-billing",
|
||||||
"ref": "v0.0.1"
|
"ref": "main"
|
||||||
},
|
},
|
||||||
"Services": {
|
"Services": {
|
||||||
"repository": "solidtime-io/extension-services",
|
"repository": "solidtime-io/extension-services",
|
||||||
"ref": "v0.0.1"
|
"ref": "v0.0.2"
|
||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.1"
|
"ref": "v0.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2195
package-lock.json
generated
2195
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@
|
|||||||
"@floating-ui/core": "^1.7.5",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/vue": "^1.1.11",
|
"@floating-ui/vue": "^1.1.11",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@lucide/vue": "^1.14.0",
|
"@lucide/vue": "^1.28.0",
|
||||||
"@rushstack/eslint-patch": "^1.16.1",
|
"@rushstack/eslint-patch": "^1.16.1",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tanstack/vue-form": "^1.32.0",
|
"@tanstack/vue-form": "^1.32.0",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"@tanstack/vue-virtual": "^3.13.24",
|
"@tanstack/vue-virtual": "^3.13.24",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.4.0",
|
||||||
"@vueuse/integrations": "^14.3.0",
|
"@vueuse/integrations": "^14.3.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"chroma-js": "^3.2.0",
|
"chroma-js": "^3.2.0",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"parse-duration": "^2.1.6",
|
"parse-duration": "^2.1.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"radix-vue": "^1.9.17",
|
"radix-vue": "^1.9.17",
|
||||||
"reka-ui": "^2.9.7",
|
"reka-ui": "^2.10.1",
|
||||||
"tailwind-merge": "^2.6.1",
|
"tailwind-merge": "^2.6.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-draggable-plus": "^0.6.1",
|
"vue-draggable-plus": "^0.6.1",
|
||||||
|
|||||||
@@ -43,5 +43,7 @@
|
|||||||
<env name="NEWSLETTER_URL" value="null"/>
|
<env name="NEWSLETTER_URL" value="null"/>
|
||||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
||||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
||||||
|
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
|
||||||
|
<env name="PADDLE_SANDBOX" value="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -7,18 +7,17 @@ import { type Client } from '@/packages/api/src';
|
|||||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import {
|
import {
|
||||||
useVueTable,
|
useSortableTable,
|
||||||
getCoreRowModel,
|
type SortableColumnDef,
|
||||||
getSortedRowModel,
|
type SortDirection,
|
||||||
type SortingState,
|
} from '@/utils/useSortableTable';
|
||||||
} from '@tanstack/vue-table';
|
|
||||||
|
|
||||||
export type SortColumn = 'name' | 'projects_count' | 'status';
|
export type SortColumn = 'name' | 'projects_count' | 'status';
|
||||||
export type SortDirection = 'asc' | 'desc';
|
export type { SortDirection } from '@/utils/useSortableTable';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
@@ -44,17 +43,7 @@ const projectCountMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Name is always the secondary sort so rows with equal values render
|
const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [
|
||||||
// alphabetically instead of in API (created_at) order.
|
|
||||||
const sorting = computed<SortingState>(() => [
|
|
||||||
{
|
|
||||||
id: props.sortColumn,
|
|
||||||
desc: props.sortDirection === 'desc',
|
|
||||||
},
|
|
||||||
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const columns = computed(() => [
|
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Client) => row.name.toLowerCase(),
|
accessorFn: (row: Client) => row.name.toLowerCase(),
|
||||||
@@ -70,41 +59,22 @@ const columns = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const descFirstColumns = new Set<SortColumn>(
|
const {
|
||||||
columns.value
|
sortedRows: sortedClients,
|
||||||
.filter((c) => 'sortDescFirst' in c && c.sortDescFirst)
|
descFirstColumns,
|
||||||
.map((c) => c.id as SortColumn)
|
nextDirection,
|
||||||
);
|
} = useSortableTable({
|
||||||
|
data: () => props.clients,
|
||||||
|
columns: () => columns.value,
|
||||||
|
sortColumn: () => props.sortColumn,
|
||||||
|
sortDirection: () => props.sortDirection,
|
||||||
|
tieBreakColumn: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
function handleSort(column: SortColumn) {
|
||||||
if (props.sortColumn === column) {
|
emit('sort', column, nextDirection(column));
|
||||||
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = useVueTable({
|
|
||||||
get data() {
|
|
||||||
return props.clients;
|
|
||||||
},
|
|
||||||
get columns() {
|
|
||||||
return columns.value;
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
state: {
|
|
||||||
get sorting() {
|
|
||||||
return sorting.value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
manualSorting: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedClients = computed(() => {
|
|
||||||
return table.getRowModel().rows.map((row) => row.original);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
||||||
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
|
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -9,57 +9,29 @@ const props = defineProps<{
|
|||||||
descFirstColumns: ReadonlySet<SortColumn>;
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
defineEmits<{
|
||||||
sort: [column: SortColumn];
|
sort: [column: SortColumn];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
emit('sort', column);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSorted(column: SortColumn): boolean {
|
|
||||||
return props.sortColumn === column;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChevronDown(column: SortColumn): boolean {
|
|
||||||
if (!isSorted(column)) return false;
|
|
||||||
return props.descFirstColumns.has(column)
|
|
||||||
? props.sortDirection === 'desc'
|
|
||||||
: props.sortDirection === 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChevronUp(column: SortColumn): boolean {
|
|
||||||
if (!isSorted(column)) return false;
|
|
||||||
return !isChevronDown(column);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<SortableTableHeaderCell
|
||||||
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
|
||||||
@click="handleSort('name')">
|
column="name"
|
||||||
|
v-bind="props"
|
||||||
|
@sort="$emit('sort', $event)">
|
||||||
Name
|
Name
|
||||||
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
<SortableTableHeaderCell
|
||||||
<span v-else class="w-4 h-4"></span>
|
column="projects_count"
|
||||||
</div>
|
v-bind="props"
|
||||||
<div
|
@sort="$emit('sort', $event)">
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('projects_count')">
|
|
||||||
Projects
|
Projects
|
||||||
<ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('projects_count')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('status')">
|
|
||||||
Status
|
Status
|
||||||
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,16 +3,14 @@ import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vu
|
|||||||
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
|
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
|
||||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||||
import type { Member } from '@/packages/api/src';
|
import type { Member } from '@/packages/api/src';
|
||||||
import { computed } from 'vue';
|
|
||||||
import {
|
import {
|
||||||
useVueTable,
|
useSortableTable,
|
||||||
getCoreRowModel,
|
type SortableColumnDef,
|
||||||
getSortedRowModel,
|
type SortDirection,
|
||||||
type SortingState,
|
} from '@/utils/useSortableTable';
|
||||||
} from '@tanstack/vue-table';
|
|
||||||
|
|
||||||
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
|
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
|
||||||
export type SortDirection = 'asc' | 'desc';
|
export type { SortDirection } from '@/utils/useSortableTable';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sortColumn: SortColumn;
|
sortColumn: SortColumn;
|
||||||
@@ -33,14 +31,7 @@ const roleOrder: Record<string, number> = {
|
|||||||
placeholder: 4,
|
placeholder: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sorting = computed<SortingState>(() => [
|
const columns: SortableColumnDef<Member, SortColumn>[] = [
|
||||||
{
|
|
||||||
id: props.sortColumn,
|
|
||||||
desc: props.sortDirection === 'desc',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Member) => row.name.toLowerCase(),
|
accessorFn: (row: Member) => row.name.toLowerCase(),
|
||||||
@@ -56,7 +47,6 @@ const columns = [
|
|||||||
{
|
{
|
||||||
id: 'billable_rate',
|
id: 'billable_rate',
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
sortUndefined: 'last' as const,
|
|
||||||
accessorFn: (row: Member) => {
|
accessorFn: (row: Member) => {
|
||||||
if (row.billable_rate === null) return undefined;
|
if (row.billable_rate === null) return undefined;
|
||||||
return row.billable_rate;
|
return row.billable_rate;
|
||||||
@@ -68,36 +58,21 @@ const columns = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const descFirstColumns = new Set<SortColumn>(
|
const {
|
||||||
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
|
sortedRows: sortedMembers,
|
||||||
);
|
descFirstColumns,
|
||||||
|
nextDirection,
|
||||||
|
} = useSortableTable({
|
||||||
|
data: () => members.value,
|
||||||
|
columns: () => columns,
|
||||||
|
sortColumn: () => props.sortColumn,
|
||||||
|
sortDirection: () => props.sortDirection,
|
||||||
|
tieBreakColumn: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
function handleSort(column: SortColumn) {
|
||||||
if (props.sortColumn === column) {
|
emit('sort', column, nextDirection(column));
|
||||||
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = useVueTable({
|
|
||||||
get data() {
|
|
||||||
return members.value;
|
|
||||||
},
|
|
||||||
columns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
state: {
|
|
||||||
get sorting() {
|
|
||||||
return sorting.value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
manualSorting: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedMembers = computed(() => {
|
|
||||||
return table.getRowModel().rows.map((row) => row.original);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
||||||
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -9,73 +9,35 @@ const props = defineProps<{
|
|||||||
descFirstColumns: ReadonlySet<SortColumn>;
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
defineEmits<{
|
||||||
sort: [column: SortColumn];
|
sort: [column: SortColumn];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
emit('sort', column);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSorted(column: SortColumn): boolean {
|
|
||||||
return props.sortColumn === column;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChevronDown(column: SortColumn): boolean {
|
|
||||||
if (!isSorted(column)) return false;
|
|
||||||
return props.descFirstColumns.has(column)
|
|
||||||
? props.sortDirection === 'desc'
|
|
||||||
: props.sortDirection === 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChevronUp(column: SortColumn): boolean {
|
|
||||||
if (!isSorted(column)) return false;
|
|
||||||
return !isChevronDown(column);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<SortableTableHeaderCell
|
||||||
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
|
||||||
@click="handleSort('name')">
|
column="name"
|
||||||
|
v-bind="props"
|
||||||
|
@sort="$emit('sort', $event)">
|
||||||
Name
|
Name
|
||||||
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="email" v-bind="props" @sort="$emit('sort', $event)">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('email')">
|
|
||||||
Email
|
Email
|
||||||
<ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('email')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="role" v-bind="props" @sort="$emit('sort', $event)">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('role')">
|
|
||||||
Role
|
Role
|
||||||
<ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
|
<SortableTableHeaderCell
|
||||||
<span v-else class="w-4 h-4"></span>
|
column="billable_rate"
|
||||||
</div>
|
v-bind="props"
|
||||||
<div
|
@sort="$emit('sort', $event)">
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('billable_rate')">
|
|
||||||
Billable Rate
|
Billable Rate
|
||||||
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('status')">
|
|
||||||
Status
|
Status
|
||||||
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,17 +6,12 @@ import { computed, ref, watch } from 'vue';
|
|||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||||
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
|
||||||
export type SortColumn =
|
export type SortColumn =
|
||||||
| 'name'
|
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
|
||||||
| 'client_name'
|
export type { SortDirection } from '@/utils/useSortableTable';
|
||||||
| 'spent_time'
|
|
||||||
| 'progress'
|
|
||||||
| 'billable_rate'
|
|
||||||
| 'status'
|
|
||||||
| 'visibility';
|
|
||||||
export type SortDirection = 'asc' | 'desc';
|
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
@@ -27,20 +22,24 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import {
|
import {
|
||||||
useVueTable,
|
useSortableTable,
|
||||||
getCoreRowModel,
|
type SortableColumnDef,
|
||||||
getSortedRowModel,
|
type SortDirection,
|
||||||
type SortingState,
|
} from '@/utils/useSortableTable';
|
||||||
} from '@tanstack/vue-table';
|
|
||||||
|
|
||||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
projects: Project[];
|
defineProps<{
|
||||||
showBillableRate: boolean;
|
projects: Project[];
|
||||||
sortColumn: SortColumn;
|
showBillableRate: boolean;
|
||||||
sortDirection: SortDirection;
|
sortColumn: SortColumn;
|
||||||
}>();
|
sortDirection: SortDirection;
|
||||||
|
isFiltered?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ isFiltered: false, isLoading: false }
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
sort: [column: SortColumn, direction: SortDirection];
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
@@ -57,28 +56,16 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert sort props to TanStack Table format.
|
|
||||||
// Name is always the secondary sort so rows with equal values render
|
|
||||||
// alphabetically instead of in API (created_at) order.
|
|
||||||
const sorting = computed<SortingState>(() => [
|
|
||||||
{
|
|
||||||
id: props.sortColumn,
|
|
||||||
desc: props.sortDirection === 'desc',
|
|
||||||
},
|
|
||||||
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Define column accessors for sorting.
|
// Define column accessors for sorting.
|
||||||
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
||||||
// while text columns default to ascending (A-Z) on first click (chevron down).
|
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||||
const columns = computed(() => [
|
const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'client_name',
|
id: 'client_name',
|
||||||
sortUndefined: 'last' as const,
|
|
||||||
accessorFn: (row: Project) => {
|
accessorFn: (row: Project) => {
|
||||||
if (!row.client_id) return undefined;
|
if (!row.client_id) return undefined;
|
||||||
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
||||||
@@ -87,12 +74,11 @@ const columns = computed(() => [
|
|||||||
{
|
{
|
||||||
id: 'spent_time',
|
id: 'spent_time',
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
accessorFn: (row: Project) => row.spent_time ?? 0,
|
accessorFn: (row: Project) => row.spent_time,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'progress',
|
id: 'progress',
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
sortUndefined: 'last' as const,
|
|
||||||
accessorFn: (row: Project) => {
|
accessorFn: (row: Project) => {
|
||||||
if (!row.estimated_time) return undefined;
|
if (!row.estimated_time) return undefined;
|
||||||
return (row.spent_time / row.estimated_time) * 100;
|
return (row.spent_time / row.estimated_time) * 100;
|
||||||
@@ -101,7 +87,7 @@ const columns = computed(() => [
|
|||||||
{
|
{
|
||||||
id: 'billable_rate',
|
id: 'billable_rate',
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
accessorFn: (row: Project) => row.billable_rate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
@@ -113,40 +99,22 @@ const columns = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Columns with sortDescFirst get desc as default direction on first click.
|
const {
|
||||||
const descFirstColumns = new Set<SortColumn>(
|
sortedRows: sortedProjects,
|
||||||
columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
|
descFirstColumns,
|
||||||
);
|
nextDirection,
|
||||||
|
} = useSortableTable({
|
||||||
|
data: () => props.projects,
|
||||||
|
columns: () => columns.value,
|
||||||
|
sortColumn: () => props.sortColumn,
|
||||||
|
sortDirection: () => props.sortDirection,
|
||||||
|
tieBreakColumn: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
function handleSort(column: SortColumn) {
|
||||||
if (props.sortColumn === column) {
|
emit('sort', column, nextDirection(column));
|
||||||
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = useVueTable({
|
|
||||||
get data() {
|
|
||||||
return props.projects;
|
|
||||||
},
|
|
||||||
get columns() {
|
|
||||||
return columns.value;
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
state: {
|
|
||||||
get sorting() {
|
|
||||||
return sorting.value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
manualSorting: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedProjects = computed(() => {
|
|
||||||
return table.getRowModel().rows.map((row) => row.original);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
@@ -160,6 +128,28 @@ const paginatedProjects = computed(() => {
|
|||||||
return sortedProjects.value.slice(start, start + PAGE_SIZE);
|
return sortedProjects.value.slice(start, start + PAGE_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emptyState = computed(() => {
|
||||||
|
if (props.isFiltered) {
|
||||||
|
return {
|
||||||
|
title: 'No matching projects',
|
||||||
|
description: 'Try a different search term or adjust your filters.',
|
||||||
|
showCreateButton: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!canCreateProjects()) {
|
||||||
|
return {
|
||||||
|
title: 'You are not a member of any projects',
|
||||||
|
description: 'Ask your manager to add you to a project as a team member.',
|
||||||
|
showCreateButton: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: 'No projects found',
|
||||||
|
description: 'Create your first project now!',
|
||||||
|
showCreateButton: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const showCreateProjectModal = ref(false);
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
@@ -193,24 +183,21 @@ const gridTemplate = computed(() => {
|
|||||||
:sort-direction="props.sortDirection"
|
:sort-direction="props.sortDirection"
|
||||||
:desc-first-columns="descFirstColumns"
|
:desc-first-columns="descFirstColumns"
|
||||||
@sort="handleSort"></ProjectTableHeading>
|
@sort="handleSort"></ProjectTableHeading>
|
||||||
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
|
<div
|
||||||
|
v-if="props.isLoading"
|
||||||
|
class="col-span-full flex justify-center items-center py-24">
|
||||||
|
<LoadingSpinner></LoadingSpinner>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="sortedProjects.length === 0"
|
||||||
|
class="col-span-full py-24 text-center">
|
||||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
<h3 class="text-text-primary font-semibold">
|
<h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3>
|
||||||
{{
|
|
||||||
canCreateProjects()
|
|
||||||
? 'No projects found'
|
|
||||||
: 'You are not a member of any projects'
|
|
||||||
}}
|
|
||||||
</h3>
|
|
||||||
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
|
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
|
||||||
{{
|
{{ emptyState.description }}
|
||||||
canCreateProjects()
|
|
||||||
? 'Create your first project now!'
|
|
||||||
: 'Ask your manager to add you to a project as a team member.'
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
v-if="canCreateProjects()"
|
v-if="emptyState.showCreateButton"
|
||||||
:icon="PlusIcon"
|
:icon="PlusIcon"
|
||||||
@click="showCreateProjectModal = true"
|
@click="showCreateProjectModal = true"
|
||||||
>Create your First Project
|
>Create your First Project
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
||||||
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -14,90 +15,51 @@ const emit = defineEmits<{
|
|||||||
sort: [column: SortColumn];
|
sort: [column: SortColumn];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// Bound once per cell instead of repeating the three sort props on every column.
|
||||||
|
const sortState = computed(() => ({
|
||||||
|
sortColumn: props.sortColumn,
|
||||||
|
sortDirection: props.sortDirection,
|
||||||
|
descFirstColumns: props.descFirstColumns,
|
||||||
|
}));
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
function handleSort(column: SortColumn) {
|
||||||
emit('sort', column);
|
emit('sort', column);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSorted(column: SortColumn): boolean {
|
|
||||||
return props.sortColumn === column;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChevronDown(column: SortColumn): boolean {
|
|
||||||
if (!isSorted(column)) return false;
|
|
||||||
return props.descFirstColumns.has(column)
|
|
||||||
? props.sortDirection === 'desc'
|
|
||||||
: props.sortDirection === 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChevronUp(column: SortColumn): boolean {
|
|
||||||
if (!isSorted(column)) return false;
|
|
||||||
return !isChevronDown(column);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<SortableTableHeaderCell
|
||||||
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="pr-3 pl-2 sm:pl-4 lg:pl-6"
|
||||||
@click="handleSort('name')">
|
column="name"
|
||||||
|
v-bind="sortState"
|
||||||
|
@sort="handleSort">
|
||||||
Name
|
Name
|
||||||
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('client_name')">
|
|
||||||
Client
|
Client
|
||||||
<ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="spent_time" v-bind="sortState" @sort="handleSort">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('spent_time')">
|
|
||||||
Total Time
|
Total Time
|
||||||
<ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="progress" v-bind="sortState" @sort="handleSort">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('progress')">
|
|
||||||
Progress
|
Progress
|
||||||
<ChevronDownIcon v-if="isChevronDown('progress')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('progress')" class="w-4 h-4" />
|
<SortableTableHeaderCell
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
column="billable_rate"
|
||||||
@click="handleSort('billable_rate')">
|
v-bind="sortState"
|
||||||
|
@sort="handleSort">
|
||||||
Billable Rate
|
Billable Rate
|
||||||
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('status')">
|
|
||||||
Status
|
Status
|
||||||
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
|
||||||
@click="handleSort('visibility')">
|
|
||||||
Visibility
|
Visibility
|
||||||
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
|
</SortableTableHeaderCell>
|
||||||
<ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
|
<div class="relative py-1.5 pl-3 pr-2 sm:pr-4 lg:pr-6">
|
||||||
<span v-else class="w-4 h-4"></span>
|
|
||||||
</div>
|
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user