Compare commits

..

44 Commits

Author SHA1 Message Date
Gregor Vostrak
7117571f50 add playwright e2e support for extensions 2026-10-08 00:14:19 +02:00
Constantin Graf
fde944a84f Updated extensions 2026-10-07 18:43:16 +02:00
Constantin Graf
34273a4863 Add audit owner through parent model
Models that belong to an organization via a parent model (for example
project members via their project) implement AuditableThroughParent and
return the parent relation. The owner organization of their audits is
taken from the loaded parent if it matches the foreign key, otherwise it
is queried. The declaration is also used to backfill the owner of
existing audits.
2026-10-07 18:43:16 +02:00
Constantin Graf
6fe721fc26 Updated extensions to audit improvement branches 2026-10-07 18:43:16 +02:00
Constantin Graf
f921452419 Add marker for auditable models without owner
Models implementing AuditableWithoutOwner intentionally have no audit
owner, either because they belong to neither an organization nor a user,
or because they and their audits have to be kept when the owner is
deleted (for example billing records). Adds helpers and a scope on the
audit model to find audits that are missing an owner although they
should have one.
2026-10-07 18:43:16 +02:00
Constantin Graf
30a90f80e0 Updated extensions to audit improvement branches 2026-10-07 18:43:16 +02:00
Constantin Graf
135984f9ef Add owner organization and owner user to audits
- Rename the audit actor columns user_type/user_id to actor_type/actor_id
- Add owner_organization_id and owner_user_id to the audits table as
  foreign keys with cascade on delete, so that the audits of an
  organization or user are deleted together with it. The indexes and
  foreign keys are created without blocking writes on the large table.
- Fill the owner columns for new audits via CustomAuditable. Organizations
  and users no longer record their own deletion audit, since it would
  reference the already deleted owner.
2026-10-07 18:43:16 +02:00
Constantin Graf
af54b0db73 Updated auditing extension 2026-10-07 18:43:16 +02:00
Constantin Graf
3fc2cec751 Updated services extension 2026-10-07 18:43:16 +02:00
Constantin Graf
32ac8841bc Remove debug output from TrustHostsTest 2026-10-07 18:28:29 +02:00
Constantin Graf
1582e6f4c3 Generate profile photo in user factory locally
Faker's image() downloads the image from via.placeholder.com, which no
longer exists. Since the domain resolves again but does not respond,
the download (without timeout) hangs and the PHPUnit runs time out.
The profile photo is now generated locally with GD, which also stores a
real image instead of the temporary file path returned by image().
2026-10-07 18:28:29 +02:00
Constantin Graf
01281d80d0 Rename DB_SSLMODE env to DB_SSL_MODE and add DATABASE_URL fallback
The Laravel 13 config update made sslmode read DB_SSLMODE, which the
self-hosting examples set to require, breaking instances whose database
does not support SSL. Use DB_SSL_MODE instead so existing values are
ignored again, and fall back to DATABASE_URL when DB_URL is not set.
2026-10-07 16:23:11 +02:00
Gregor Vostrak
bb5a7fb9f9 improve API docs 2026-10-05 17:27:04 +02:00
Constantin Graf
349623d537 Reverted “Updated UUID generation” 2026-09-24 13:31:26 +02:00
Constantin Graf
a9d016d773 Added deletion of refresh tokens in DeletionService 2026-09-24 13:31:26 +02:00
Constantin Graf
2ebbbceefb Updated billing extension 2026-09-24 13:31:26 +02:00
Gregor Vostrak
e85058aefd add 1password ignore attributes for time tracker component inputs 2026-09-24 13:28:48 +02:00
Constantin Graf
a86c18ad2d Prevent non-primary mouse buttons from resizing events 2026-09-24 11:55:32 +02:00
dependabot[bot]
f683c03ff9 Bump the minor-updates group across 1 directory with 5 updates
Bumps the minor-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [laravel/fortify](https://github.com/laravel/fortify) | `1.39.0` | `1.40.0` |
| [laravel/framework](https://github.com/laravel/framework) | `13.32.0` | `13.33.0` |
| [laravel/octane](https://github.com/laravel/octane) | `2.19.1` | `2.20.0` |
| [laravel/sail](https://github.com/laravel/sail) | `1.67.0` | `1.68.0` |
| [laravel/telescope](https://github.com/laravel/telescope) | `5.24.0` | `5.25.0` |



Updates `laravel/fortify` from 1.39.0 to 1.40.0
- [Release notes](https://github.com/laravel/fortify/releases)
- [Changelog](https://github.com/laravel/fortify/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/fortify/compare/v1.39.0...v1.40.0)

Updates `laravel/framework` from 13.32.0 to 13.33.0
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v13.32.0...v13.33.0)

Updates `laravel/octane` from 2.19.1 to 2.20.0
- [Release notes](https://github.com/laravel/octane/releases)
- [Changelog](https://github.com/laravel/octane/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/octane/compare/v2.19.1...v2.20.0)

Updates `laravel/sail` from 1.67.0 to 1.68.0
- [Release notes](https://github.com/laravel/sail/releases)
- [Changelog](https://github.com/laravel/sail/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/sail/compare/v1.67.0...v1.68.0)

Updates `laravel/telescope` from 5.24.0 to 5.25.0
- [Release notes](https://github.com/laravel/telescope/releases)
- [Changelog](https://github.com/laravel/telescope/blob/5.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/telescope/compare/v5.24.0...v5.25.0)

---
updated-dependencies:
- dependency-name: laravel/fortify
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/framework
  dependency-version: 13.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/octane
  dependency-version: 2.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/sail
  dependency-version: 1.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/telescope
  dependency-version: 5.25.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 11:35:35 +02:00
Constantin Graf
fa0bbc8cfd Add permission config to npm-format-check GitHub action 2026-09-24 11:31:38 +02:00
Constantin Graf
fff3502e53 Updated UUID generation 2026-09-24 11:31:38 +02:00
Constantin Graf
e801c4311c Add new extension auditing 2026-09-24 11:31:38 +02:00
Constantin Graf
2dadf1ef02 Updated Laravel to v13 2026-09-24 10:45:18 +02:00
Constantin Graf
12cc3f27f8 Add check for premium to telemetry and update lookup 2026-09-24 10:45:18 +02:00
Constantin Graf
06a2048771 Add basic e2e tests for admin panel 2026-09-24 10:45:18 +02:00
Constantin Graf
a12ee1b029 Address review feedback on composer updates
- Default INERTIA_SSR_ENABLED to false to match .env.example, .env.ci and
  .env.production, so existing setups keep the previous behaviour.
- Stop tracking the published Filament assets under /public/fonts/filament and
  add them to .gitignore, as recommended by the Filament docs. They are
  regenerated by filament:assets via the filament:upgrade post-autoload-dump
  hook. /public/css and /public/js are already ignored.
- Drop the now dead theme.css filter from the Vite asset list in app.blade.php,
  since the custom Filament theme was removed along with viteTheme().
2026-09-24 10:45:18 +02:00
Constantin Graf
54dc0dbabc Add psysh to gitignore 2026-09-24 10:45:18 +02:00
Constantin Graf
08f9edbc96 Add additional tests for filament 2026-09-24 10:45:18 +02:00
Constantin Graf
0551c633a3 Add github action to prevent non-tag refs in manifest.json 2026-09-24 10:45:18 +02:00
Constantin Graf
37579b1e5c Updated compose dependencies 2026-09-24 10:45:18 +02:00
Constantin Graf
b33cf00788 Remove Group from ClockifyTimeEntriesImporter 2026-09-22 18:08:49 +02:00
Gregor Vostrak
c23f09fb2d add date range navigation, unify picker components 2026-09-22 15:53:50 +02:00
Gregor Vostrak
a0262addb7 fix e2e submit focus test behaviour 2026-09-22 13:43:42 +02:00
Gregor Vostrak
b4911ebddd move project reset button into mass update modal and fix
TimeTrackerProjectTaskDropdown trigger focus
2026-09-22 13:43:42 +02:00
Gregor Vostrak
3ade20887b fix cmd+enter submit shortcut on modals #1238 2026-09-22 13:43:42 +02:00
dependabot[bot]
4943a38ffe Bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 12:02:12 +02:00
dependabot[bot]
12e355ff26 Bump codecov/codecov-action from 7.0.0 to 7.1.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 7.0.0 to 7.1.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v7.0.0...v7.1.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 12:01:59 +02:00
Constantin Graf
01b60c0f6a Add validation for task name length 2026-09-22 12:01:48 +02:00
Constantin Graf
927da7dce9 Fixed type check 2026-09-22 12:01:48 +02:00
Constantin Graf
c89fa87b0f Fix clockfiy importer, allow project imports without Billability 2026-09-22 12:01:48 +02:00
Constantin Graf
02cf600f43 Add placeholder paddle api key 2026-09-21 22:07:14 +02:00
Constantin Graf
98a460725f Add GitHub action for phpunit tests with extensions 2026-09-21 22:07:14 +02:00
Constantin Graf
95645ddd91 Fixed composer auth username 2026-09-21 18:28:02 +02:00
Constantin Graf
1de985b577 Update docker image 2026-09-21 16:08:13 +02:00
165 changed files with 4637 additions and 1299 deletions

View File

@@ -6,6 +6,7 @@ APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com
# Logging # Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack

View File

@@ -103,6 +103,8 @@ jobs:
{ {
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)" echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)" echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
@@ -119,9 +121,26 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci
@@ -141,7 +160,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.5.2 uses: docker/login-action@v4.6.0
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +214,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.5.2 uses: docker/login-action@v4.6.0
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -84,6 +84,8 @@ jobs:
echo "services_ref=$(jq -r '.Services.ref' 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_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)" echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension" - name: "Checkout billing extension"
@@ -97,7 +99,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
@@ -145,6 +147,25 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Auditing"
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Setup PHP with PECL extension" - name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@@ -168,6 +189,9 @@ jobs:
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci
@@ -177,7 +201,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.5.2 uses: docker/login-action@v4.6.0
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

@@ -117,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.5.2 uses: docker/login-action@v4.6.0
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.5.2 uses: docker/login-action@v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -177,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.5.2 uses: docker/login-action@v4.6.0
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.5.2 uses: docker/login-action@v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -0,0 +1,31 @@
name: Extension Manifest Check
on:
pull_request:
paths:
- extensions/manifest.json
- .github/workflows/extension-manifest-check.yml
permissions:
contents: read
jobs:
check-refs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
# Extension refs must be pinned to a release tag (vX.Y.Z) or a full commit
# SHA. Branch names (main, feature/foo, ...) are only acceptable while
# developing and must be replaced before the pull request is merged.
- name: "Check extension refs are pinned"
run: |
failed=0
while IFS=$'\t' read -r name ref; do
if [[ "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$ ]] || [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
echo "✅ $name: $ref"
else
echo "::error file=extensions/manifest.json::Extension \"$name\" uses ref \"$ref\", which is not a release tag or commit SHA. Pin it before merging."
failed=1
fi
done < <(jq -r 'to_entries[] | [.key, .value.ref] | @tsv' extensions/manifest.json)
exit $failed

View File

@@ -1,6 +1,8 @@
name: NPM Format Check name: NPM Format Check
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
format-check: format-check:

136
.github/workflows/phpunit-extensions.yml vendored Normal file
View 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

View File

@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.0.0 uses: codecov/codecov-action@v7.1.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -0,0 +1,185 @@
name: Playwright Tests - Extensions
on: push
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
timeout-minutes: 60
# Same extension sets as build-private.yml and build-onpremise.yml, so the extension specs run
# with and without Billing.
strategy:
fail-fast: false
matrix:
include:
- variant: private
billing: true
extensions: Billing Services Invoicing
- variant: onpremise
billing: false
extensions: Invoicing
services:
mailpit:
image: 'axllent/mailpit:latest'
ports:
- 1025:1025
- 8025:8025
pgsql_test:
image: postgres:15
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 node"
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- 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"
if: matrix.billing
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"
if: matrix.billing
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"
if: matrix.billing
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
if: matrix.billing
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"
if: matrix.billing
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
if: matrix.billing
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
# Must run before `migrate` so the extensions' migrations are applied, and before the
# frontend build so vite collects the extensions' assets. It also writes
# modules_statuses.json, which playwright.config.ts reads to collect the extension specs.
- name: "Activate extensions"
run: |
for extension in ${{ matrix.extensions }}; do
php artisan module:enable "$extension"
done
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
php artisan migrate --seed
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Install FrankenPHP"
run: |
ARCH="$(uname -m)"
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
chmod +x /usr/local/bin/frankenphp
- name: "Run Laravel Octane Server"
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
env:
OCTANE_SERVER: frankenphp
- name: "Install Playwright Browsers"
run: npx playwright install --with-deps
# Only the extension specs, the core specs run without extensions in playwright.yml
- name: "Run Playwright tests"
run: npx playwright test extensions/
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
MAILPIT_BASE_URL: 'http://localhost:8025'
# No artifacts are uploaded on purpose. This repository is public, so every artifact is
# downloadable by anyone, and Playwright's reports carry the source of the specs that
# produced them (traces embed whole source files, error-context.md embeds a window of the
# spec). Those specs live in the private extension repositories. Failures are diagnosed from
# the job log; to inspect a trace, reproduce the failure locally with the extension checked out.

2
.gitignore vendored
View File

@@ -6,6 +6,7 @@ dist
/public/storage /public/storage
/public/css /public/css
/public/js /public/js
/public/fonts/filament
/public/vendor /public/vendor
/lang/vendor /lang/vendor
/storage/*.key /storage/*.key
@@ -43,4 +44,5 @@ yarn-error.log
/data /data
/config/caddy /config/caddy
/config/composer /config/composer
/config/psysh
/AGENTS.md /AGENTS.md

View File

@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages\CreateAudit;
use App\Filament\Resources\AuditResource\Pages\ListAudits;
use App\Filament\Resources\AuditResource\Pages\ViewAudit;
use App\Models\Audit;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|\UnitEnum|null $navigationGroup = 'System';
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('user_type')
->maxLength(255),
TextInput::make('user_id'),
TextInput::make('event')
->required()
->maxLength(255),
TextInput::make('auditable_type')
->required()
->maxLength(255),
TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Textarea::make('url'),
TextInput::make('ip_address'),
TextInput::make('user_agent')
->maxLength(1023),
TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name'),
TextColumn::make('event'),
TextColumn::make('auditable_type'),
TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
TextColumn::make('created_at')
->sortable()
->dateTime(),
TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
])
->toolbarActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => ListAudits::route('/'),
'create' => CreateAudit::route('/create'),
'view' => ViewAudit::route('/{record}'),
];
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -23,7 +23,7 @@ use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto; use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService; use App\Service\Import\ImportService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Exception; use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -40,6 +40,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use League\Flysystem\UnableToReadFile;
class OrganizationResource extends Resource class OrganizationResource extends Resource
{ {
@@ -88,7 +89,7 @@ class OrganizationResource extends Resource
Select::make('currency') Select::make('currency')
->label('Currency') ->label('Currency')
->options(function (): array { ->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = []; $select = [];
foreach ($currencies as $currency) { foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
@@ -181,9 +182,13 @@ class OrganizationResource extends Resource
->icon('heroicon-o-inbox-arrow-down') ->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data): void { ->action(function (Organization $record, array $data): void {
try { try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
} catch (UnableToReadFile) {
$file = null;
}
if ($file === null) { if ($file === null) {
throw new Exception('File not found'); throw new ImportException('File not found');
} }
/** @var string $timezone */ /** @var string $timezone */
$timezone = $data['timezone']; $timezone = $data['timezone'];

View File

@@ -98,9 +98,10 @@ class UsersRelationManager extends RelationManager
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member); app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
} }
if ($data['role'] !== $member->role) { $newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
try { try {
app(MemberService::class)->changeRole($member, $organization, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true); app(MemberService::class)->changeRole($member, $organization, $newRole, true);
} catch (ApiException $exception) { } catch (ApiException $exception) {
Notification::make() Notification::make()
->danger() ->danger()

View File

@@ -16,7 +16,7 @@ use App\Models\User;
use App\Service\DeletionService; use App\Service\DeletionService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService; use App\Service\UserService;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Exception; use Exception;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -115,7 +115,7 @@ class UserResource extends Resource
Select::make('currency') Select::make('currency')
->label('Currency (Personal Organization)') ->label('Currency (Personal Organization)')
->options(function (): array { ->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = []; $select = [];
foreach ($currencies as $currency) { foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';

View File

@@ -59,9 +59,10 @@ class OrganizationsRelationManager extends RelationManager
/** @var Member $member */ /** @var Member $member */
$member = $record->getRelation('membership'); $member = $record->getRelation('membership');
if ($data['role'] !== $member->role) { $newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
try { try {
app(MemberService::class)->changeRole($member, $record, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true); app(MemberService::class)->changeRole($member, $record, $newRole, true);
} catch (ApiException $exception) { } catch (ApiException $exception) {
Notification::make() Notification::make()
->danger() ->danger()

View File

@@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Service\CurrencyService; use App\Service\CurrencyService;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller class CurrencyController extends Controller
@@ -29,7 +29,7 @@ class CurrencyController extends Controller
'name' => $currency->getName(), 'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()), 'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
], ],
ISOCurrencyProvider::getInstance()->getAvailableCurrencies() IsoCurrencyProvider::getInstance()->getAvailableCurrencies()
)); ));
return response()->json($currencies); return response()->json($currencies);

View File

@@ -88,6 +88,8 @@ class InvitationController extends Controller
/** /**
* Remove a pending invitation * Remove a pending invitation
* *
* This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId removeInvitation * @operationId removeInvitation

View File

@@ -145,6 +145,9 @@ class MemberController extends Controller
/** /**
* Merge one member into another * Merge one member into another
* *
* Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder
* are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws Throwable * @throws Throwable

View File

@@ -71,6 +71,8 @@ class ReportController extends Controller
/** /**
* Create report * Create report
* *
* A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId createReport * @operationId createReport

View File

@@ -109,9 +109,14 @@ class TimeEntryController extends Controller
/** /**
* Get time entries in organization * Get time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `member_id`. * Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own.
* To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* *
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first.
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
*
* @return TimeEntryCollection<TimeEntryResource> * @return TimeEntryCollection<TimeEntryResource>
* *
* @throws AuthorizationException * @throws AuthorizationException
@@ -191,8 +196,9 @@ class TimeEntryController extends Controller
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) { if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']); $select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start'); // These SQL fragments are generated from a rounding enum and validated minute count.
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end'); $select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start'); // @phpstan-ignore argument.type
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end'); // @phpstan-ignore argument.type
} }
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
@@ -350,6 +356,11 @@ class TimeEntryController extends Controller
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria. * The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
* *
* Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money).
* Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Array filters use the query format `client_ids[]=<id>`.
* Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`.
*
* @operationId getAggregatedTimeEntries * @operationId getAggregatedTimeEntries
* *
* @return array{ * @return array{
@@ -583,6 +594,12 @@ class TimeEntryController extends Controller
/** /**
* Create time entry * Create time entry
* *
* `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project).
*
* A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`.
* To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry.
* To log past work, send both `start` and `end` (UTC). Create one entry per block of work.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws TimeEntryStillRunningApiException * @throws TimeEntryStillRunningApiException
* *
@@ -633,6 +650,8 @@ class TimeEntryController extends Controller
/** /**
* Update time entry * Update time entry
* *
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
*
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
* *
* @operationId updateTimeEntry * @operationId updateTimeEntry
@@ -701,6 +720,10 @@ class TimeEntryController extends Controller
/** /**
* Update multiple time entries * Update multiple time entries
* *
* Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries`
* (filtered by `member_id`, the project and the other criteria), then send their IDs here.
* When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project.
*
* @operationId updateMultipleTimeEntries * @operationId updateMultipleTimeEntries
* *
* @throws AuthorizationException * @throws AuthorizationException

View File

@@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Models\Organization; use App\Models\Organization;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -53,7 +53,7 @@ class OrganizationController extends Controller
], ],
'currencies' => array_map(function (Currency $currency): string { 'currencies' => array_map(function (Currency $currency): string {
return $currency->getName(); return $currency->getName();
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()), }, IsoCurrencyProvider::getInstance()->getAvailableCurrencies()),
'permissions' => [ 'permissions' => [
'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'), 'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'),
'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'), 'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'),

View File

@@ -11,6 +11,7 @@ use App\Http\Middleware\EnsureEmailIsVerified;
use App\Http\Middleware\ForceHttps; use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ForceJsonResponse; use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\PreventRequestForgery;
use App\Http\Middleware\PreventRequestsDuringMaintenance; 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;
@@ -18,7 +19,6 @@ use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts; 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 Illuminate\Auth\Middleware\AuthenticateWithBasicAuth; use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize; use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\RequirePassword; use Illuminate\Auth\Middleware\RequirePassword;
@@ -68,7 +68,7 @@ class Kernel extends HttpKernel
AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
StartSession::class, StartSession::class,
ShareErrorsFromSession::class, ShareErrorsFromSession::class,
VerifyCsrfToken::class, PreventRequestForgery::class,
SubstituteBindings::class, SubstituteBindings::class,
HandleInertiaRequests::class, HandleInertiaRequests::class,
ShareInertiaData::class, ShareInertiaData::class,

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Filament\Http\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class AuthenticateFilamentPanel extends Middleware
{
/**
* The admin panel has no login page of its own (it shares the app's
* session-based login), so Filament::getLoginUrl() always returns null.
* Fall back to the app's login route so guests are redirected instead
* of receiving an empty 401 response.
*/
protected function redirectTo($request): ?string
{
return parent::redirectTo($request) ?? ($request->expectsJson() ? null : route('login'));
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as Middleware;
class PreventRequestForgery extends Middleware
{
/**
* The URIs that should be excluded.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -27,6 +27,7 @@ class MemberUpdateRequest extends BaseFormRequest
'string', 'string',
Rule::enum(Role::class), Rule::enum(Role::class),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -36,6 +36,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string', 'string',
new CurrencyRule, new CurrencyRule,
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -55,6 +55,7 @@ class ProjectStoreRequest extends BaseFormRequest
'required', 'required',
'boolean', 'boolean',
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -68,6 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge([ 'billable_rate' => array_merge([
'nullable', 'nullable',
], ],

View File

@@ -31,6 +31,7 @@ class ProjectMemberStoreRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -21,6 +21,7 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Filter by member ID // Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
Rule::enum(TimeEntryType::class), Rule::enum(TimeEntryType::class),
], ],
// Limit the number of returned time entries (default: 150) // Limit the number of returned time entries (default: 100)
'limit' => [ 'limit' => [
'integer', 'integer',
'min:1', 'min:1',

View File

@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID)
'member_id' => [ 'member_id' => [
'required', 'required',
'string', 'string',
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start', 'after_or_equal:start',
], ],
// Whether time entry is billable // Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app
'billable' => [ 'billable' => [
'required', 'required',
'boolean', 'boolean',

View File

@@ -4,15 +4,19 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory; use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel; use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/** /**
* @property int $id * @property int $id
* @property string|null $user_type * @property string|null $actor_type
* @property string|null $user_id * @property string|null $actor_id
* @property string $event * @property string $event
* @property string $auditable_type * @property string $auditable_type
* @property string $auditable_id * @property string $auditable_id
@@ -22,13 +26,71 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string|null $ip_address * @property string|null $ip_address
* @property string|null $user_agent * @property string|null $user_agent
* @property string|null $tags * @property string|null $tags
* @property string|null $owner_user_id
* @property string|null $owner_organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read User|null $ownerUser
* @property-read Organization|null $ownerOrganization
* *
* @method static AuditFactory factory() * @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/ */
class Audit extends PackageAuditModel class Audit extends PackageAuditModel
{ {
/** @use HasFactory<AuditFactory> */ /** @use HasFactory<AuditFactory> */
use HasFactory; use HasFactory;
/**
* @return BelongsTo<User, $this>
*/
public function ownerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsTo<Organization, $this>
*/
public function ownerOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'owner_organization_id');
}
/**
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
*/
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
{
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
}
/**
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
*
* @return array<int, string>
*/
public static function getAuditableTypesWithoutOwner(): array
{
return collect(Relation::morphMap())
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
->keys()
->values()
->all();
}
/**
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
* These are audits whose owner no longer exists or could not be determined (yet).
*
* @param Builder<Audit> $builder
*/
public function scopeWhereMissingOwner(Builder $builder): void
{
$builder->whereNull('owner_organization_id')
->whereNull('owner_user_id')
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
}
} }

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Marks an auditable model whose audits are owned by the owner of its parent model,
* because the model itself has no organization_id (for example a project member belongs to an organization via its project).
* The parent model has to have an organization_id column.
* This is used to set the owner of new audits (CustomAuditable) and to backfill the owner of existing audits.
*/
interface AuditableThroughParent
{
/**
* @return BelongsTo<covariant Model, covariant Model>
*/
public function getAuditParentRelation(): BelongsTo;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
/**
* Marks an auditable model whose audits intentionally have no owner (neither an organization nor a user),
* either because the model belongs to neither of them, or because the model and its audits have to be kept
* when its organization or user is deleted (for example billing records).
* Audits of these models are not deleted together with an organization or user,
* and are not considered as missing an owner (for example by the audit backfill command).
*/
interface AuditableWithoutOwner {}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models\Concerns; namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable; use OwenIt\Auditing\Auditable;
trait CustomAuditable trait CustomAuditable
@@ -19,4 +21,92 @@ trait CustomAuditable
{ {
$this->auditEvents = []; $this->auditEvents = [];
} }
/**
* The organization that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
*/
public function getAuditOwnerOrganizationId(): ?string
{
if ($this instanceof AuditableThroughParent) {
$relation = $this->getAuditParentRelation();
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
if ($parentId === null) {
return null;
}
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
$relationName = $relation->getRelationName();
if ($this->relationLoaded($relationName)) {
$parent = $this->getRelation($relationName);
if ($parent instanceof Model
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
&& array_key_exists('organization_id', $parent->getAttributes())) {
/** @var string|null $organizationId */
$organizationId = $parent->getAttributes()['organization_id'];
return $organizationId;
}
}
/** @var string|null $organizationId */
$organizationId = $relation->getRelated()->newQuery()
->toBase()
->where($relation->getOwnerKeyName(), $parentId)
->value('organization_id');
return $organizationId;
}
return $this->getAttributes()['organization_id'] ?? null;
}
/**
* The user that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
*/
public function getAuditOwnerUserId(): ?string
{
return null;
}
/**
* Models that are the owner of their own audits can not record the deletion audit,
* since the audit would reference the already deleted model and therefore violate the foreign key.
*/
protected function isAuditOwnerOfItself(): bool
{
return false;
}
/**
* @return array<int|string, string>
*/
public function getAuditEvents(): array
{
$events = $this->auditEvents ?? Config::get('audit.events', [
'created',
'updated',
'deleted',
'restored',
]);
if ($this->isAuditOwnerOfItself()) {
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
}
return $events;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function transformAudit(array $data): array
{
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
$data['owner_user_id'] = $this->getAuditOwnerUserId();
return $data;
}
} }

View File

@@ -19,7 +19,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -97,10 +96,20 @@ class Organization extends Model implements AuditableContract
protected $attributes = [ protected $attributes = [
]; ];
public function getAuditOwnerOrganizationId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/** /**
* Get all the users that belong to the team. * Get all the users that belong to the team.
* *
* @return BelongsToMany<User, $this, Pivot, 'membership'> * @return BelongsToMany<User, $this, Member, 'membership'>
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
@@ -133,7 +142,7 @@ class Organization extends Model implements AuditableContract
} }
/** /**
* @return BelongsToMany<User, $this, Pivot, 'membership'> * @return BelongsToMany<User, $this, Member, 'membership'>
*/ */
public function realUsers(): BelongsToMany public function realUsers(): BelongsToMany
{ {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory; use Database\Factories\ProjectMemberFactory;
@@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization) * @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory() * @method static ProjectMemberFactory factory()
*/ */
class ProjectMember extends Model implements AuditableContract class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
{ {
use CustomAuditable; use CustomAuditable;
@@ -82,4 +83,12 @@ class ProjectMember extends Model implements AuditableContract
$query->whereBelongsTo($organization, 'organization'); $query->whereBelongsTo($organization, 'organization');
}); });
} }
/**
* @return BelongsTo<Project, $this>
*/
public function getAuditParentRelation(): BelongsTo
{
return $this->project();
}
} }

View File

@@ -20,7 +20,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -125,6 +124,16 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'send_time_entry_still_running_email' => true, 'send_time_entry_still_running_email' => true,
]; ];
public function getAuditOwnerUserId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/** /**
* Get the URL to the user's profile photo. * Get the URL to the user's profile photo.
* *
@@ -175,7 +184,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'> * @return BelongsToMany<Organization, $this, Member, 'membership'>
*/ */
public function organizations(): BelongsToMany public function organizations(): BelongsToMany
{ {
@@ -190,7 +199,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'> * @return BelongsToMany<Organization, $this, Member, 'membership'>
*/ */
public function ownedOrganizations(): BelongsToMany public function ownedOrganizations(): BelongsToMany
{ {

View File

@@ -9,7 +9,7 @@ use App\Filament\Widgets\ServerOverview;
use App\Filament\Widgets\TimeEntriesCreated; use App\Filament\Widgets\TimeEntriesCreated;
use App\Filament\Widgets\TimeEntriesImported; use App\Filament\Widgets\TimeEntriesImported;
use App\Filament\Widgets\UserRegistrations; use App\Filament\Widgets\UserRegistrations;
use Filament\Http\Middleware\Authenticate; use App\Http\Middleware\AuthenticateFilamentPanel;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup; use Filament\Navigation\NavigationGroup;
@@ -19,7 +19,7 @@ use Filament\PanelProvider;
use Filament\Support\Colors\Color; use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession; use Illuminate\Session\Middleware\StartSession;
@@ -71,6 +71,9 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make() NavigationGroup::make()
->label('Auth') ->label('Auth')
->collapsed(), ->collapsed(),
NavigationGroup::make()
->label('Self Hosting')
->collapsed(),
]) ])
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
@@ -78,13 +81,13 @@ class AdminPanelProvider extends PanelProvider
StartSession::class, StartSession::class,
AuthenticateSession::class, AuthenticateSession::class,
ShareErrorsFromSession::class, ShareErrorsFromSession::class,
VerifyCsrfToken::class, PreventRequestForgery::class,
SubstituteBindings::class, SubstituteBindings::class,
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->authMiddleware([ ->authMiddleware([
Authenticate::class, AuthenticateFilamentPanel::class,
]); ]);
$modules = Module::allEnabled(); $modules = Module::allEnabled();

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Rules; namespace App\Rules;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString; use Illuminate\Translation\PotentiallyTranslatedString;
@@ -24,7 +24,7 @@ class CurrencyRule implements ValidationRule
return; return;
} }
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
if (array_key_exists($value, $currencies)) { if (array_key_exists($value, $currencies)) {
return; return;
} }

View File

@@ -15,12 +15,18 @@ use App\Models\User;
use Exception; use Exception;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Log; use Log;
use Nwidart\Modules\Facades\Module;
use Throwable; use Throwable;
class ApiService class ApiService
{ {
private const string API_URL = 'https://app.solidtime.io/api/v1'; private const string API_URL = 'https://app.solidtime.io/api/v1';
private function isPremium(): bool
{
return Module::has('Invoicing');
}
public function checkForUpdate(): ?string public function checkForUpdate(): ?string
{ {
try { try {
@@ -31,6 +37,7 @@ class ApiService
'version' => config('app.version'), 'version' => config('app.version'),
'build' => config('app.build'), 'build' => config('app.build'),
'url' => config('app.url'), 'url' => config('app.url'),
'is_premium' => $this->isPremium(),
]); ]);
if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) { if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {
@@ -62,6 +69,7 @@ class ApiService
'version' => config('app.version'), 'version' => config('app.version'),
'build' => config('app.build'), 'build' => config('app.build'),
'url' => config('app.url'), 'url' => config('app.url'),
'is_premium' => $this->isPremium(),
// telemetry data // telemetry data
'user_count' => User::count(), 'user_count' => User::count(),
'organization_count' => Organization::count(), 'organization_count' => Organization::count(),

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use Brick\Money\ISOCurrencyProvider; use Brick\Money\IsoCurrencyProvider;
use Brick\Money\Money; use Brick\Money\Money;
class CurrencyService class CurrencyService
@@ -378,7 +378,7 @@ class CurrencyService
public function getRandomCurrencyCode(): string public function getRandomCurrencyCode(): string
{ {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencyCodes = array_keys($currencies); $currencyCodes = array_keys($currencies);
return $currencyCodes[array_rand($currencyCodes)]; return $currencyCodes[array_rand($currencyCodes)];

View File

@@ -152,12 +152,13 @@ class DashboardService
$possibleDays = $this->lastDays($days, $timezone); $possibleDays = $this->lastDays($days, $timezone);
// The SQL fragment contains only a timezone offset calculated as an integer.
$query = TimeEntry::query() $query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) // @phpstan-ignore argument.type
->where('user_id', '=', $user->getKey()) ->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey()) ->where('organization_id', '=', $organization->getKey())
->workTime() ->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) // @phpstan-ignore argument.type
->orderBy('date'); ->orderBy('date');
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
@@ -194,12 +195,13 @@ class DashboardService
} }
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start); $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
// The SQL fragment contains only a timezone offset calculated as an integer.
$query = TimeEntry::query() $query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) // @phpstan-ignore argument.type
->where('user_id', '=', $user->getKey()) ->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey()) ->where('organization_id', '=', $organization->getKey())
->workTime() ->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) // @phpstan-ignore argument.type
->orderBy('date'); ->orderBy('date');
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);

View File

@@ -11,6 +11,7 @@ use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\Passport\RefreshToken;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\Report; use App\Models\Report;
@@ -169,6 +170,10 @@ class DeletionService
} }
} }
// Refresh tokens are not linked to the user directly, so they need to be deleted via their access tokens.
// Otherwise a still-valid refresh token could be used to issue a new access token for a deleted user,
// which fails with a foreign key violation on oauth_access_tokens.user_id.
RefreshToken::query()->whereIn('access_token_id', $user->accessTokens()->pluck('id'))->delete();
$user->accessTokens()->delete(); $user->accessTokens()->delete();
$user->authCodes()->delete(); $user->authCodes()->delete();

View File

@@ -45,7 +45,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => ($record['Billability'] ?? '') === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null, 'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
@@ -55,6 +55,7 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($tasksKey !== null && $record[$tasksKey] !== '') { if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]); $tasks = explode(', ', $record[$tasksKey]);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->checkTaskNameLength($task);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
'project_id' => $projectId, 'project_id' => $projectId,
@@ -84,7 +85,6 @@ class ClockifyProjectsImporter extends DefaultImporter
'Project', 'Project',
'Status', 'Status',
'Visibility', 'Visibility',
'Billability',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {

View File

@@ -104,6 +104,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') { if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
$this->checkTaskNameLength($record[$taskKey]);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey], 'name' => $record[$taskKey],
'project_id' => $projectId, 'project_id' => $projectId,
@@ -228,7 +229,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Description', 'Description',
'User', 'User',
'Group',
'Email', 'Email',
'Tags', 'Tags',
'Start Date', 'Start Date',

View File

@@ -22,6 +22,8 @@ use Override;
abstract class DefaultImporter implements ImporterContract abstract class DefaultImporter implements ImporterContract
{ {
protected const TASK_NAME_MAX_LENGTH = 500;
protected Organization $organization; protected Organization $organization;
/** /**
@@ -182,6 +184,16 @@ abstract class DefaultImporter implements ImporterContract
$this->billableRateService = app(BillableRateService::class); $this->billableRateService = app(BillableRateService::class);
} }
/**
* @throws ImportException
*/
protected function checkTaskNameLength(string $taskName): void
{
if (strlen($taskName) > self::TASK_NAME_MAX_LENGTH) {
throw new ImportException('Task name ("'.$taskName.'") is too long, maximum length is '.self::TASK_NAME_MAX_LENGTH.' characters');
}
}
#[Override] #[Override]
public function getReport(): ReportDto public function getReport(): ReportDto
{ {

View File

@@ -114,6 +114,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['task'] !== '') { if ($record['task'] !== '') {
$this->checkTaskNameLength($record['task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['task'], 'name' => $record['task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -92,6 +92,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -199,6 +199,7 @@ class SolidtimeImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task['name']);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task['name'], 'name' => $task['name'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -153,9 +153,16 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($projectMembers as $projectMember) { foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
if ($userId === null) {
throw new Exception('User does not exist');
}
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($userId);
if ($memberId === null) {
throw new Exception('Member does not exist');
}
$this->projectMemberImportHelper->getKey([ $this->projectMemberImportHelper->getKey([
'project_id' => $projectId, 'project_id' => $projectId,
'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId), 'member_id' => $memberId,
], [ ], [
'user_id' => $userId, 'user_id' => $userId,
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
@@ -182,6 +189,7 @@ class TogglDataImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task->name);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task->name, 'name' => $task->name,
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -98,6 +98,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -75,8 +75,9 @@ class TimeEntryAggregationService
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes); $startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes); $endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
// SQL fragments come from enum cases, integer offsets, and formatted dates.
$timeEntriesQuery->selectRaw( $timeEntriesQuery->selectRaw(
($group1Select !== null ? $group1Select.' as group_1,' : ''). ($group1Select !== null ? $group1Select.' as group_1,' : ''). // @phpstan-ignore argument.type
($group2Select !== null ? $group2Select.' as group_2,' : ''). ($group2Select !== null ? $group2Select.' as group_2,' : '').
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
@@ -105,7 +106,7 @@ class TimeEntryAggregationService
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone(); $baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query $baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
->selectRaw( ->selectRaw(
$group1Select.' as group_1,'. $group1Select.' as group_1,'. // @phpstan-ignore argument.type
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
) )
@@ -170,7 +171,7 @@ class TimeEntryAggregationService
// Reset selects and ordering on the cloned base query // Reset selects and ordering on the cloned base query
$baseTotals = $baseTotalsQuery $baseTotals = $baseTotalsQuery
->selectRaw( ->selectRaw(
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. // @phpstan-ignore argument.type
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
) )
->first(); ->first();

View File

@@ -7,22 +7,22 @@
"require": { "require": {
"php": "8.3.*", "php": "8.3.*",
"ext-zip": "*", "ext-zip": "*",
"brick/money": "^0.11.2", "brick/money": "^0.15.1",
"datomatic/laravel-enum-helper": "^2.0.0", "datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.13.43", "dedoc/scramble": "^0.13.43",
"filament/filament": "^5.0", "filament/filament": "^5.0",
"flowframe/laravel-trend": "^0.5.0", "flowframe/laravel-trend": "^0.5.0",
"gotenberg/gotenberg-php": "^2.8", "gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^8.2",
"inertiajs/inertia-laravel": "^3.3.4", "inertiajs/inertia-laravel": "^3.3.4",
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1", "korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0", "korridor/laravel-model-validation-rules": "^3.0",
"laravel/fortify": "^1.37", "laravel/fortify": "^1.37",
"laravel/framework": "^12.19.3", "laravel/framework": "^13.0",
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^13.0.5", "laravel/passport": "^13.0.5",
"laravel/tinker": "^2.8", "laravel/tinker": "^3.0",
"league/csv": "^9.16.0", "league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3", "league/iso3166": "^4.3",

749
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,9 @@ return [
| Application Name | Application Name
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This value is the name of your application. This value is used when the | This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or | framework needs to place the application's name in a notification or
| any other location as required by the application or its packages. | other UI elements where an application name needs to be displayed.
| |
*/ */
@@ -110,8 +110,8 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may specify the default timezone for your application, which | Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone | will be used by the PHP date and date-time functions. The timezone
| ahead and set this to a sensible default for you out of the box. | is set to "UTC" by default as it is suitable for most use cases.
| |
*/ */
@@ -123,8 +123,8 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| The application locale determines the default locale that will be used | The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value | by Laravel's translation / localization methods. This option can be
| to any of the locales which will be supported by the application. | set to any locale for which you plan to have translation strings.
| |
*/ */
@@ -178,15 +178,21 @@ return [
| Encryption Key | Encryption Key
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This key is used by the Illuminate encrypter service and should be set | This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string, otherwise these encrypted strings | to a random, 32 character string to ensure that all encrypted values
| will not be safe. Please do this before deploying an application! | are secure. You should do this prior to deploying the application.
| |
*/ */
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'), 'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC', 'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
'localization' => [ 'localization' => [
'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'), 'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'),
@@ -206,13 +212,13 @@ return [
| manage Laravel's "maintenance mode" status. The "cache" driver will | manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines. | allow maintenance mode to be controlled across multiple machines.
| |
| Supported drivers: "file", "cache" | Supported drivers: "file", "cache", "array"
| |
*/ */
'maintenance' => [ 'maintenance' => [
'driver' => 'file', 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
// 'store' => 'redis', 'store' => env('APP_MAINTENANCE_STORE', 'database'),
], ],
/* /*

View File

@@ -32,7 +32,7 @@ return [
*/ */
'user' => [ 'user' => [
'morph_prefix' => 'user', 'morph_prefix' => 'actor',
'guards' => [ 'guards' => [
'web', 'web',
'api', 'api',

View File

@@ -10,8 +10,8 @@ return [
| Authentication Defaults | Authentication Defaults
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This option controls the default authentication "guard" and password | This option defines the default authentication "guard" and password
| reset options for your application. You may change these defaults | reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications. | as required, but they're a perfect start for most applications.
| |
*/ */
@@ -28,11 +28,11 @@ return [
| |
| Next, you may define every authentication guard for your application. | Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you | Of course, a great default configuration has been defined for you
| here which uses session storage and the Eloquent user provider. | which utilizes session storage plus the Eloquent user provider.
| |
| All authentication drivers have a user provider. This defines how the | All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage | users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data. | system used by the application. Typically, Eloquent is utilized.
| |
| Supported: "session" | Supported: "session"
| |
@@ -55,12 +55,12 @@ return [
| User Providers | User Providers
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| All authentication drivers have a user provider. This defines how the | All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage | users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data. | system used by the application. Typically, Eloquent is utilized.
| |
| If you have multiple user tables or models you may configure multiple | If you have multiple user tables or models you may configure multiple
| sources which represent each model / table. These sources may then | providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined. | be assigned to any extra authentication guards you have defined.
| |
| Supported: "database", "eloquent" | Supported: "database", "eloquent"
@@ -80,9 +80,9 @@ return [
| Resetting Passwords | Resetting Passwords
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| You may specify multiple password reset configurations if you have more | These configuration options specify the behavior of Laravel's password
| than one user table or model in the application and you want to have | reset functionality, including the table utilized for token storage
| separate password reset settings based on the specific user types. | and the user provider that is invoked to actually retrieve users.
| |
| The expiry time is the number of minutes that each reset token will be | The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so | considered valid. This security feature keeps tokens short-lived so
@@ -108,13 +108,13 @@ return [
| Password Confirmation Timeout | Password Confirmation Timeout
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may define the amount of seconds before a password confirmation | Here you may define the number of seconds before a password confirmation
| times out and the user is prompted to re-enter their password via the | window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours. | confirmation screen. By default, the timeout lasts for three hours.
| |
*/ */
'password_timeout' => 10800, 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')), 'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')),

View File

@@ -11,9 +11,9 @@ return [
| Default Cache Store | Default Cache Store
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This option controls the default cache connection that gets used while | This option controls the default cache store that will be used by the
| using this caching library. This connection is used when another is | framework. This connection is utilized if another isn't explicitly
| not explicitly specified when executing a given caching function. | specified when running a cache operation inside the application.
| |
*/ */
@@ -28,8 +28,9 @@ return [
| well as their drivers. You may even define multiple stores for the | well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches. | same cache driver to group types of items stored in your caches.
| |
| Supported drivers: "apc", "array", "database", "file", | Supported drivers: "array", "database", "file", "memcached",
| "memcached", "redis", "dynamodb", "octane", "null" | "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
| |
*/ */
@@ -46,9 +47,10 @@ return [
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'table' => 'cache', 'connection' => env('DB_CACHE_CONNECTION'),
'connection' => null, 'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => null, 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
], ],
'file' => [ 'file' => [
@@ -57,6 +59,12 @@ return [
'lock_path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data'),
], ],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [ 'memcached' => [
'driver' => 'memcached', 'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
@@ -78,8 +86,8 @@ return [
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => 'cache', 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => 'default', 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
], ],
'dynamodb' => [ 'dynamodb' => [
@@ -95,6 +103,14 @@ return [
'driver' => 'octane', 'driver' => 'octane',
], ],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
], ],
/* /*
@@ -102,12 +118,24 @@ return [
| Cache Key Prefix | Cache Key Prefix
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| When utilizing the APC, database, memcached, Redis, or DynamoDB cache | When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores there might be other applications using the same cache. For | stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions. | that reason, you may prefix every cache key to avoid collisions.
| |
*/ */
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
]; ];

View File

@@ -12,26 +12,22 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may specify which of the database connections below you wish | Here you may specify which of the database connections below you wish
| to use as your default connection for all database work. Of course | to use as your default connection for database operations. This is
| you may use many connections at once using the Database library. | the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
| |
*/ */
'default' => env('DB_CONNECTION', 'mysql'), 'default' => env('DB_CONNECTION', 'pgsql'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Database Connections | Database Connections
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here are each of the database connections setup for your application. | Below are all of the database connections defined for your application.
| Of course, examples of configuring each database platform that is | An example configuration is provided for each database system which
| supported by Laravel is shown below to make development simple. | is supported by Laravel. You're free to add / remove connections.
|
|
| All database work in Laravel is done through the PHP PDO facilities
| so make sure you have the driver for your particular database of
| choice installed on your machine before you begin development.
| |
*/ */
@@ -39,50 +35,34 @@ return [
'sqlite' => [ 'sqlite' => [
'driver' => 'sqlite', 'driver' => 'sqlite',
'url' => env('DATABASE_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'database' => env('DB_DATABASE', database_path('database.sqlite')), 'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '', 'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
], 'busy_timeout' => null,
'journal_mode' => null,
'mysql' => [ 'synchronous' => null,
'driver' => 'mysql', 'transaction_mode' => 'DEFERRED',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
'pgsql' => [ 'pgsql' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DATABASE_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_HOST', '127.0.0.1'), 'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'), 'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'), 'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'), 'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''), 'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8', 'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => 'prefer', 'sslmode' => env('DB_SSL_MODE', 'prefer'),
], ],
'pgsql_test' => [ 'pgsql_test' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DATABASE_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_TEST_HOST', '127.0.0.1'), 'host' => env('DB_TEST_HOST', '127.0.0.1'),
'port' => env('DB_TEST_PORT', '5432'), 'port' => env('DB_TEST_PORT', '5432'),
'database' => env('DB_TEST_DATABASE', 'forge'), 'database' => env('DB_TEST_DATABASE', 'forge'),
@@ -92,18 +72,18 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => 'prefer', 'sslmode' => env('DB_SSL_MODE', 'prefer'),
], ],
'sqlsrv' => [ 'sqlsrv' => [
'driver' => 'sqlsrv', 'driver' => 'sqlsrv',
'url' => env('DATABASE_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_HOST', 'localhost'), 'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'), 'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'forge'), 'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'forge'), 'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''), 'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8', 'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'), // 'encrypt' => env('DB_ENCRYPT', 'yes'),
@@ -119,11 +99,14 @@ return [
| |
| This table keeps track of all the migrations that have already run for | This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of | your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run in the database. | the migrations on disk haven't actually been run on the database.
| |
*/ */
'migrations' => 'migrations', 'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => false,
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -132,7 +115,7 @@ return [
| |
| Redis is an open source, fast, and advanced key-value store that also | Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system | provides a richer body of commands than a typical key-value system
| such as APC or Memcached. Laravel makes it easy to dig right in. | such as Memcached. You may define your connection settings here.
| |
*/ */
@@ -143,6 +126,7 @@ return [
'options' => [ 'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'), 'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'persistent' => env('REDIS_PERSISTENT', false),
], ],
'default' => [ 'default' => [
@@ -152,6 +136,10 @@ return [
'password' => env('REDIS_PASSWORD'), 'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'), 'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
'cache' => [ 'cache' => [
@@ -161,6 +149,10 @@ return [
'password' => env('REDIS_PASSWORD'), 'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'), 'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
], ],

View File

@@ -11,7 +11,7 @@ return [
| |
| Here you may specify the default filesystem disk that should be used | Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud | by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application. Just store away! | based disks are available to your application for file storage.
| |
*/ */
@@ -26,11 +26,11 @@ return [
| Filesystem Disks | Filesystem Disks
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may configure as many filesystem "disks" as you wish, and you | Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks of the same driver. Defaults have | may even configure multiple disks for the same driver. Examples for
| been set up for each driver as an example of the required values. | most supported storage drivers are configured here for reference.
| |
| Supported Drivers: "local", "ftp", "sftp", "s3" | Supported drivers: "local", "ftp", "sftp", "s3"
| |
*/ */
@@ -41,14 +41,16 @@ return [
'root' => storage_path('app'), 'root' => storage_path('app'),
'serve' => true, 'serve' => true,
'throw' => true, 'throw' => true,
'report' => false,
], ],
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), 'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage', 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public', 'visibility' => 'public',
'throw' => true, 'throw' => true,
'report' => false,
], ],
's3' => [ 's3' => [
@@ -62,6 +64,7 @@ return [
'endpoint' => env('S3_ENDPOINT'), 'endpoint' => env('S3_ENDPOINT'),
'use_path_style_endpoint' => env('S3_USE_PATH_STYLE_ENDPOINT', false), 'use_path_style_endpoint' => env('S3_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true, 'throw' => true,
'report' => false,
], ],
'testfiles' => [ 'testfiles' => [

View File

@@ -23,7 +23,7 @@ return [
'ssr' => [ 'ssr' => [
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true), 'enabled' => (bool) env('INERTIA_SSR_ENABLED', false),
'runtime' => env('INERTIA_SSR_RUNTIME', 'node'), 'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),

View File

@@ -14,9 +14,9 @@ return [
| Default Log Channel | Default Log Channel
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This option defines the default log channel that gets used when writing | This option defines the default log channel that is utilized to write
| messages to the logs. The name specified in this option should match | messages to your logs. The value provided here should match one of
| one of the channels defined in the "channels" configuration array. | the channels present in the list of "channels" configured below.
| |
*/ */
@@ -43,20 +43,20 @@ return [
| Log Channels | Log Channels
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may configure the log channels for your application. Out of | Here you may configure the log channels for your application. Laravel
| the box, Laravel uses the Monolog PHP logging library. This gives | utilizes the Monolog PHP logging library, which includes a variety
| you a variety of powerful log handlers / formatters to utilize. | of powerful log handlers and formatters that you're free to use.
| |
| Available Drivers: "single", "daily", "slack", "syslog", | Available drivers: "single", "daily", "monthly", "slack", "syslog",
| "errorlog", "monolog", | "errorlog", "monolog", "custom", "stack"
| "custom", "stack"
| |
*/ */
'channels' => [ 'channels' => [
'stack' => [ 'stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => ['single'], 'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false, 'ignore_exceptions' => false,
], ],
@@ -81,15 +81,23 @@ return [
'driver' => 'daily', 'driver' => 'daily',
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'days' => 14, 'max_files' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'monthly' => [
'driver' => 'monthly',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => 3,
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
'slack' => [ 'slack' => [
'driver' => 'slack', 'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'), 'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log', 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => ':boom:', 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'), 'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
@@ -116,17 +124,17 @@ return [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class, 'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'), 'handler_with' => [
'with' => [
'stream' => 'php://stderr', 'stream' => 'php://stderr',
], ],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class], 'processors' => [PsrLogMessageProcessor::class],
], ],
'syslog' => [ 'syslog' => [
'driver' => 'syslog', 'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'facility' => LOG_USER, 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],

View File

@@ -9,9 +9,10 @@ return [
| Default Mailer | Default Mailer
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This option controls the default mailer that is used to send any email | This option controls the default mailer that is used to send all email
| messages sent by your application. Alternative mailers may be setup | messages unless another mailer is explicitly specified when sending
| and used as needed; however, this mailer will be used by default. | the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
| |
*/ */
@@ -26,26 +27,28 @@ return [
| their respective settings. Several examples have been configured for | their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires. | you and you are free to add your own as your application requires.
| |
| Laravel supports a variety of mail "transport" drivers to be used while | Laravel supports a variety of mail "transport" drivers that can be used
| sending an e-mail. You will specify which one you are using for your | when delivering an email. You may specify which one you're using for
| mailers below. You are free to add additional mailers as required. | your mailers below. You may also add additional mailers if needed.
| |
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "log", "array", "failover", "roundrobin" | "postmark", "resend", "log", "array",
| "failover", "roundrobin"
| |
*/ */
'mailers' => [ 'mailers' => [
'smtp' => [ 'smtp' => [
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'), 'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', 'smtp.mailgun.org'), 'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 587), 'port' => env('MAIL_PORT', 2525),
'encryption' => env('MAIL_ENCRYPTION', 'tls'), 'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'timeout' => null, 'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'), 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
], ],
'ses' => [ 'ses' => [
@@ -54,17 +57,14 @@ return [
'postmark' => [ 'postmark' => [
'transport' => 'postmark', 'transport' => 'postmark',
// 'message_stream_id' => null, // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [ // 'client' => [
// 'timeout' => 5, // 'timeout' => 5,
// ], // ],
], ],
'mailgun' => [ 'resend' => [
'transport' => 'mailgun', 'transport' => 'resend',
// 'client' => [
// 'timeout' => 5,
// ],
], ],
'sendmail' => [ 'sendmail' => [
@@ -87,6 +87,7 @@ return [
'smtp', 'smtp',
'log', 'log',
], ],
'retry_after' => 60,
], ],
'roundrobin' => [ 'roundrobin' => [
@@ -95,7 +96,9 @@ return [
'ses', 'ses',
'postmark', 'postmark',
], ],
'retry_after' => 60,
], ],
], ],
/* /*
@@ -103,15 +106,15 @@ return [
| Global "From" Address | Global "From" Address
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| You may wish for all e-mails sent by your application to be sent from | You may wish for all emails sent by your application to be sent from
| the same address. Here, you may specify a name and address that is | the same address. Here you may specify a name and address that is
| used globally for all e-mails that are sent by your application. | used globally for all emails that are sent by your application.
| |
*/ */
'from' => [ 'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'), 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
], ],
'reply_to' => [ 'reply_to' => [

View File

@@ -9,9 +9,9 @@ return [
| Default Queue Connection Name | Default Queue Connection Name
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Laravel's queue API supports an assortment of back-ends via a single | Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each back-end using the same | API, giving you convenient access to each backend using identical
| syntax for every one. Here you may define a default connection. | syntax for each. The default queue connection is defined below.
| |
*/ */
@@ -22,11 +22,12 @@ return [
| Queue Connections | Queue Connections
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may configure the connection information for each server that | Here you may configure the connection options for every queue backend
| is used by your application. A default configuration has been added | used by your application. An example configuration is provided for
| for each back-end shipped with Laravel. You are free to add more. | each backend supported by Laravel. You're also free to add more.
| |
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" | Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
| |
*/ */
@@ -38,17 +39,18 @@ return [
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'table' => 'jobs', 'connection' => env('DB_QUEUE_CONNECTION'),
'queue' => 'default', 'table' => env('DB_QUEUE_TABLE', 'jobs'),
'retry_after' => 90, 'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false, 'after_commit' => false,
], ],
'beanstalkd' => [ 'beanstalkd' => [
'driver' => 'beanstalkd', 'driver' => 'beanstalkd',
'host' => 'localhost', 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => 'default', 'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => 90, 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0, 'block_for' => 0,
'after_commit' => false, 'after_commit' => false,
], ],
@@ -66,13 +68,29 @@ return [
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => 'default', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'), 'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null, 'block_for' => null,
'after_commit' => false, 'after_commit' => false,
], ],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
], ],
/* /*
@@ -87,7 +105,7 @@ return [
*/ */
'batching' => [ 'batching' => [
'database' => env('DB_CONNECTION', 'mysql'), 'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches', 'table' => 'job_batches',
], ],
@@ -97,14 +115,16 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| These options configure the behavior of failed queue job logging so you | These options configure the behavior of failed queue job logging so you
| can control which database and table are used to store the jobs that | can control how and where failed jobs are stored. Laravel ships with
| have failed. You may change them to any database / table you wish. | support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
| |
*/ */
'failed' => [ 'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'), 'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs', 'table' => 'failed_jobs',
], ],

View File

@@ -28,7 +28,30 @@ return [
/* /*
* Description rendered on the home page of the API documentation (`/docs/api`). * Description rendered on the home page of the API documentation (`/docs/api`).
*/ */
'description' => '', 'description' => <<<'MD'
## Getting started
All organization endpoints live under `/v1/organizations/{organization}`, where `{organization}` is the organization's ID. Authenticate with `Authorization: Bearer <token>` and send `Accept: application/json`.
**1. Find yourself.** Call `GET /v1/users/me/memberships`. Each membership contains the **organization ID** (use it as `{organization}` in paths) and your **member ID** in that organization (the membership `id`). Most endpoints filter by member ID, not by user ID.
**2. Scope to your own data.** For owners and admins, `GET /time-entries` and `GET /time-entries/aggregate` return the whole organization's time entries unless you pass `member_id`. When acting for "me", always pass your member ID, both when reading and before changing entries.
**3. Resolve names to IDs.** Look up projects, clients, tags, tasks and members by name with their list endpoints (`GET /projects`, `GET /clients`, `GET /tags`, `GET /tasks`, `GET /members`). Never guess IDs.
**4. Use UTC.** All timestamps are sent and returned in UTC as `Y-m-d\TH:i:s\Z` (example: `2026-10-02T07:30:00Z`). Convert the user's local times and day boundaries to UTC before sending them.
**5. Money is in cents.** Billable rates and costs are integers in cents of the organization's currency (`8000` means 80.00).
## Common tasks
- **Start a timer:** stop the running entry first (find it with `GET /v1/users/me/time-entries/active`, then `PUT` its `end`), then `POST /time-entries` with `start` and `end: null`. Only one entry can run per member.
- **Log past work:** `POST /time-entries` once per block with `member_id`, `start`, `end`, `project_id` and `billable` set to the project's `is_billable` (it is not derived automatically).
- **Fix or stop an entry:** `PUT /time-entries/{timeEntry}` with the new `start` or `end` in UTC.
- **Move entries to another project:** list them with `member_id` and filters, then `PATCH /time-entries` with their `ids` and `changes.project_id`, plus `changes.task_id` set to a task of the new project or `null`.
- **Totals and reports:** `GET /time-entries/aggregate` with `group` (for example `client` or `project`) and `start`/`end`; durations are in `seconds`, amounts in `cost` (cents).
- **Share a report:** `POST /reports` with `is_public: true` and use the returned `shareable_link`.
MD,
], ],
/* /*

View File

@@ -9,12 +9,12 @@ return [
| Default Session Driver | Default Session Driver
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This option controls the default session "driver" that will be used on | This option determines the default session driver that is utilized for
| requests. By default, we will use the lightweight native driver but | incoming requests. Laravel supports a variety of storage options to
| you may specify any of the other wonderful drivers provided here. | persist session data. Database storage is a great default choice.
| |
| Supported: "file", "cookie", "database", "apc", | Supported: "file", "cookie", "database", "memcached",
| "memcached", "redis", "dynamodb", "array" | "redis", "dynamodb", "array"
| |
*/ */
@@ -27,13 +27,14 @@ return [
| |
| Here you may specify the number of minutes that you wish the session | Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them | to be allowed to remain idle before it expires. If you want them
| to immediately expire on the browser closing, set that option. | to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
| |
*/ */
'lifetime' => env('SESSION_LIFETIME', 120), 'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => false, 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -79,24 +80,24 @@ return [
| Session Database Table | Session Database Table
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| When using the "database" session driver, you may specify the table we | When using the "database" session driver, you may specify the table to
| should use to manage the sessions. Of course, a sensible default is | be used to store sessions. Of course, a sensible default is defined
| provided for you; however, you are free to change this as needed. | for you; however, you're welcome to change this to another table.
| |
*/ */
'table' => 'sessions', 'table' => env('SESSION_TABLE', 'sessions'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Session Cache Store | Session Cache Store
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| While using one of the framework's cache driven session backends you may | When using one of the framework's cache driven session backends, you may
| list a cache store that should be used for these sessions. This value | define the cache store which should be used to store the session data
| must match with one of the application's configured cache "stores". | between requests. This must match one of your defined cache stores.
| |
| Affects: "apc", "dynamodb", "memcached", "redis" | Affects: "dynamodb", "memcached", "redis"
| |
*/ */
@@ -120,9 +121,9 @@ return [
| Session Cookie Name | Session Cookie Name
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may change the name of the cookie used to identify a session | Here you may change the name of the session cookie that is created by
| instance by ID. The name specified here will get used every time a | the framework. Typically, you should not need to change this value
| new session cookie is created by the framework for every driver. | since doing so does not grant a meaningful security improvement.
| |
*/ */
@@ -138,20 +139,20 @@ return [
| |
| The session cookie path determines the path for which the cookie will | The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of | be regarded as available. Typically, this will be the root path of
| your application but you are free to change this when necessary. | your application, but you're free to change this when necessary.
| |
*/ */
'path' => '/', 'path' => env('SESSION_PATH', '/'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Session Cookie Domain | Session Cookie Domain
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may change the domain of the cookie used to identify a session | This value determines the domain and subdomains the session cookie is
| in your application. This will determine which domains the cookie is | available to. By default, the cookie will be available to the root
| available to in your application. A sensible default has been set. | domain without subdomains. Typically, this shouldn't be changed.
| |
*/ */
@@ -177,11 +178,11 @@ return [
| |
| Setting this value to true will prevent JavaScript from accessing the | Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through | value of the cookie and the cookie will only be accessible through
| the HTTP protocol. You are free to modify this option if needed. | the HTTP protocol. It's unlikely you should disable this option.
| |
*/ */
'http_only' => true, 'http_only' => env('SESSION_HTTP_ONLY', true),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -190,13 +191,15 @@ return [
| |
| This option determines how your cookies behave when cross-site requests | This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we | take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" since this is a secure default value. | will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
| |
| Supported: "lax", "strict", "none", null | Supported: "lax", "strict", "none", null
| |
*/ */
'same_site' => 'lax', 'same_site' => env('SESSION_SAME_SITE', 'lax'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -209,6 +212,22 @@ return [
| |
*/ */
'partitioned' => false, 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'php',
]; ];

View File

@@ -91,7 +91,7 @@ class UserFactory extends Factory
public function withProfilePicture(): static public function withProfilePicture(): static
{ {
$profilePhoto = $this->faker->image(null, 500, 500); $profilePhoto = $this->generateProfilePhoto();
/** @see FileHelpers::hashName */ /** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png'; $path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto); Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
@@ -103,6 +103,21 @@ class UserFactory extends Factory
}); });
} }
/**
* Generates a PNG image with a random background color.
* Note: The image is generated locally, since downloading a placeholder image (Faker image()) depends on an external service.
*/
private function generateProfilePhoto(): string
{
$image = imagecreatetruecolor(500, 500);
$color = imagecolorallocate($image, $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255));
imagefill($image, 0, 0, $color);
ob_start();
imagepng($image);
return (string) ob_get_clean();
}
/** /**
* Indicate that the user should have a personal team. * Indicate that the user should have a personal team.
*/ */

View File

@@ -18,7 +18,8 @@ class CreateAuditsTable extends Migration
Schema::connection($connection)->create($table, function (Blueprint $table): void { Schema::connection($connection)->create($table, function (Blueprint $table): void {
$morphPrefix = config('audit.user.morph_prefix', 'user'); // Note: The morph prefix is hardcoded, since the columns are renamed in a later migration
$morphPrefix = 'user';
$table->bigIncrements('id'); $table->bigIncrements('id');
$table->string($morphPrefix.'_type')->nullable(); $table->string($morphPrefix.'_type')->nullable();

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Note: Renaming columns and adding nullable columns without default are metadata-only operations in PostgreSQL,
* so this migration is fast even for a large audits table.
* The indexes and foreign keys for the new columns are added in a separate non-transactional migration.
*/
public function up(): void
{
Schema::table('audits', function (Blueprint $table): void {
$table->renameColumn('user_type', 'actor_type');
$table->renameColumn('user_id', 'actor_id');
$table->renameIndex('audits_user_id_user_type_index', 'audits_actor_id_actor_type_index');
$table->uuid('owner_user_id')->nullable();
$table->uuid('owner_organization_id')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('audits', function (Blueprint $table): void {
$table->dropColumn('owner_user_id');
$table->dropColumn('owner_organization_id');
$table->renameIndex('audits_actor_id_actor_type_index', 'audits_user_id_user_type_index');
$table->renameColumn('actor_type', 'user_type');
$table->renameColumn('actor_id', 'user_id');
});
}
};

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* PostgreSQL cannot build an index concurrently inside a transaction.
* Keeping this migration non-transactional prevents long write locks on the (large) audits table in production.
* Every step is idempotent, so the migration can be re-run if it fails halfway.
*
* @var bool
*/
public $withinTransaction = false;
/**
* Run the migrations.
*/
public function up(): void
{
$this->createIndex('audits_owner_user_id_index', 'owner_user_id');
$this->createIndex('audits_owner_organization_id_index', 'owner_organization_id');
$this->createForeignKey('audits_owner_user_id_foreign', 'owner_user_id', 'users');
$this->createForeignKey('audits_owner_organization_id_foreign', 'owner_organization_id', 'organizations');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_user_id_foreign');
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_organization_id_foreign');
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_user_id_index');
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_organization_id_index');
}
private function createIndex(string $index, string $column): void
{
$state = DB::selectOne(
<<<'SQL'
SELECT pg_index.indisvalid::int AS valid
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 && (bool) $state->valid) {
return;
}
// A failed concurrent index build leaves an invalid index behind, that needs to be dropped before rebuilding
if ($state !== null) {
DB::statement('DROP INDEX'.$this->concurrently().' '.$index);
}
DB::statement('CREATE INDEX'.$this->concurrently().' '.$index.' ON audits ('.$column.')');
}
private function createForeignKey(string $constraint, string $column, string $referencedTable): void
{
$exists = DB::selectOne(
<<<'SQL'
SELECT 1
FROM pg_constraint
JOIN pg_namespace ON pg_namespace.oid = pg_constraint.connamespace
WHERE pg_namespace.nspname = current_schema()
AND pg_constraint.conname = ?
SQL,
[$constraint],
) !== null;
// Note: Adding the constraint as NOT VALID only needs a short lock, the validation of the existing rows
// afterward does not block reads or writes on the audits table.
if (! $exists) {
DB::statement('ALTER TABLE audits ADD CONSTRAINT '.$constraint.' FOREIGN KEY ('.$column.') REFERENCES '.$referencedTable.' (id) ON DELETE CASCADE NOT VALID');
}
DB::statement('ALTER TABLE audits VALIDATE CONSTRAINT '.$constraint);
}
private function concurrently(): string
{
return DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
}
};

View File

@@ -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,106 +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} . ./
RUN test -z "$(find . -name .git -print -quit)"
#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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

74
e2e/admin-panel.spec.ts Normal file
View File

@@ -0,0 +1,74 @@
import { adminTest as test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
/**
* Index pages of all Filament resources that ship with the core application.
* Resources provided by extensions are intentionally left out, because
* extensions are not installed in CI.
*/
const resourcePages = [
{ path: '/admin/time-entries', heading: 'Time Entries' },
{ path: '/admin/projects', heading: 'Projects' },
{ path: '/admin/tasks', heading: 'Tasks' },
{ path: '/admin/clients', heading: 'Clients' },
{ path: '/admin/tags', heading: 'Tags' },
{ path: '/admin/reports', heading: 'Reports' },
{ path: '/admin/users', heading: 'Users' },
{ path: '/admin/organizations', heading: 'Organizations' },
{ path: '/admin/organization-invitations', heading: 'Invitations' },
{ path: '/admin/project-members', heading: 'Project Members' },
{ path: '/admin/tokens', heading: 'Tokens' },
{ path: '/admin/failed-jobs', heading: 'Failed Jobs' },
];
test.describe('Admin Panel Access', () => {
test.use({ storageState: undefined });
test('test that the admin panel redirects guests to the login page', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/admin');
await expect(page).toHaveURL(PLAYWRIGHT_BASE_URL + '/login');
});
test('test that the admin panel is forbidden for users that are no super admin', async ({
page,
}) => {
const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
await page.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD);
await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
const response = await page.goto(PLAYWRIGHT_BASE_URL + '/admin');
expect(response?.status()).toBe(403);
});
});
test.describe('Admin Panel', () => {
test('test that the admin dashboard loads with all widgets', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/admin');
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
// The widgets are loaded lazily via Livewire, so they only show up once
// their content has been rendered.
await expect(page.getByText('Total real users')).toBeVisible();
await expect(page.getByText('Placeholder users')).toBeVisible();
await expect(page.getByText('Active users in the last seven days')).toBeVisible();
await expect(page.getByText('User Registrations', { exact: true })).toBeVisible();
await expect(page.getByText('Time Entries Created', { exact: true })).toBeVisible();
await expect(page.getByText('Time Entries Imported', { exact: true })).toBeVisible();
});
for (const { path, heading } of resourcePages) {
test(`test that the admin page ${path} loads`, async ({ page }) => {
const response = await page.goto(PLAYWRIGHT_BASE_URL + path);
expect(response?.status()).toBe(200);
await expect(page.getByRole('heading', { name: heading, level: 1 })).toBeVisible();
});
}
});

View File

@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
test('test that calendar navigation buttons work', async ({ page }) => { test('test that calendar navigation buttons work', async ({ page }) => {
await goToCalendar(page); await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Click the "next" button to navigate forward // Navigate between named periods.
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.fc')).toBeVisible();
// Click the "prev" button to navigate back
await page.getByRole('button', { name: 'Previous' }).click(); await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.locator('.fc')).toBeVisible(); await expect(page.getByTestId('calendar-title')).toContainText('Last Week');
// Navigate forward first, then click today
await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate forward first, then use the range button to return to today.
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
await page.getByRole('button', { name: 'today' }).click(); await page.getByRole('button', { name: 'today' }).click();
await expect(page.locator('.fc')).toBeVisible(); await expect(page.getByTestId('calendar-title')).toContainText('This Week');
}); });
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {

View File

@@ -18,6 +18,25 @@ import {
// Each test registers a new user and creates test data via API // Each test registers a new user and creates test data via API
test.describe.configure({ timeout: 30000 }); test.describe.configure({ timeout: 30000 });
test('date range picker labels and navigates reporting periods', async ({ page }) => {
await goToReporting(page);
const range = page.getByTestId('date_range_picker_display');
const previous = page.getByTestId('date_range_picker_previous');
const next = page.getByTestId('date_range_picker_next');
await expect(range).toContainText('Last 14 Days');
await expect(next).toBeDisabled();
await Promise.all([waitForReportingUpdate(page), previous.click()]);
await expect(range).not.toContainText('Last 14 Days');
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
// does not necessarily trigger another aggregate request.
await next.click();
await expect(range).toContainText('Last 14 Days');
});
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Project Multiselect Dropdown Tests // Project Multiselect Dropdown Tests
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -1579,6 +1579,57 @@ test('test that project selection works in create modal', async ({ page, ctx })
expect(createBody.data.project_id).not.toBeNull(); expect(createBody.data.project_id).not.toBeNull();
}); });
test('test that ctrl+enter submits the create modal after selecting a project via keyboard', async ({
page,
ctx,
}) => {
// Regression test for https://github.com/solidtime-io/solidtime/issues/1238
const projectName = 'Keyboard Submit Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The menu that opened the modal animates out and only then hands focus back to its
// own trigger.
await expect(page.locator('[role="menu"]')).toHaveCount(0);
// Fill the description, then move to the project dropdown and select a project purely via keyboard
const description = page.getByRole('dialog').getByRole('textbox', { name: 'Description' });
await description.fill('Keyboard submit test');
await description.press('Tab');
await expect(
page.getByRole('dialog').getByRole('button', { name: 'No Project' })
).toBeFocused();
await page.keyboard.press('Enter');
await page.getByTestId('client_dropdown_search').fill(projectName);
await expect(page.getByRole('option', { name: projectName })).toBeVisible();
await page.keyboard.press('Enter');
const projectTrigger = page.getByRole('dialog').getByRole('button', { name: projectName });
await expect(projectTrigger).toBeVisible();
// The trigger label updates on the next tick, but the dropdown keeps focus until its
// exit animation has finished and reka-ui hands focus back to the trigger.
await expect(projectTrigger).toBeFocused();
// Ctrl+Enter must submit even though focus is no longer on the description input
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.keyboard.press('Control+Enter'),
]);
const createBody = await createResponse.json();
expect(createBody.data.description).toBe('Keyboard submit test');
expect(createBody.data.project_id).not.toBeNull();
await expect(page.getByRole('dialog')).toBeHidden();
const newTimeEntry = page.locator('[data-testid="time_entry_row"]').first();
await expect(newTimeEntry.getByTestId('time_entry_description').first()).toHaveValue(
'Keyboard submit test'
);
});
test('test that tag selection works in create modal', async ({ page }) => { test('test that tag selection works in create modal', async ({ page }) => {
await goToTimeOverview(page); await goToTimeOverview(page);

View File

@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
test('can navigate forward and return to current week', async ({ page }) => { test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]); await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This week" // Should show "This Week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
// Go to next week — the text assertions below auto-retry until the // Go to next week — the text assertions below auto-retry until the
// header label flips. // header label flips.
await page.getByTestId('timesheet_next_week').click(); await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This week" // Should no longer show "This Week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week'); await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
// Go back to this week // Go back to this week
await page.getByTestId('timesheet_week_display').click(); await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

24
e2e/utils/admin.ts Normal file
View File

@@ -0,0 +1,24 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
/**
* The seeded user that is allowed to access the Filament admin panel.
*
* Panel access is granted by email address via the `SUPER_ADMINS` config, so
* the admin panel tests cannot use the throwaway users created by the default
* fixture and rely on the seeded user instead.
*/
export const SUPER_ADMIN_EMAIL = 'admin@example.com';
export const SEEDED_USER_PASSWORD = 'password';
/**
* Log in as the seeded super admin user via the regular application login.
*/
export async function loginAsSuperAdmin(page: Page): Promise<void> {
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
await page.getByLabel('Email').fill(SUPER_ADMIN_EMAIL);
await page.getByLabel('Password').fill(SEEDED_USER_PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
}

View File

@@ -68,7 +68,7 @@ export async function getPasswordResetUrl(
for (let attempt = 0; attempt < 5; attempt++) { for (let attempt = 0; attempt < 5; attempt++) {
searchResult = await searchEmails( searchResult = await searchEmails(
request, request,
`to:${encodeURIComponent(recipientEmail)} subject:"Reset Password"` `to:${encodeURIComponent(recipientEmail)} subject:"Reset your password"`
); );
if (searchResult.messages.length > 0) break; if (searchResult.messages.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));

View File

@@ -1,14 +1,18 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "v0.0.6" "ref": "v0.0.9"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
"ref": "v0.0.2" "ref": "v0.0.4"
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.6" "ref": "v0.0.8"
},
"Auditing": {
"repository": "solidtime-io/extension-auditing",
"ref": "v0.0.4"
} }
} }

View File

@@ -34,6 +34,7 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/> <env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="pgsql_test"/> <env name="DB_CONNECTION" value="pgsql_test"/>
<env name="FILESYSTEM_DISK" value="local"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
@@ -43,5 +44,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>

View File

@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import { existsSync, readFileSync } from 'node:fs';
/** /**
* Read environment variables from file. * Read environment variables from file.
@@ -6,11 +7,57 @@ import { defineConfig, devices } from '@playwright/test';
*/ */
// require('dotenv').config(); // require('dotenv').config();
/**
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
* a checked out but disabled extension would fail its specs otherwise.
*/
function enabledExtensions(): string[] {
const statusesFile = './modules_statuses.json';
if (!existsSync(statusesFile)) {
return [];
}
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
return Object.entries(statuses)
.filter(([, enabled]) => enabled)
.map(([name]) => name);
}
/*
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
*/
const testRoots = [
{ suffix: '', testDir: './e2e' },
...enabledExtensions().map((name) => ({
suffix: '-' + name,
testDir: `./extensions/${name}/tests/e2e`,
})),
];
const browsers = [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Firefox only in CI to keep local runs fast
...(process.env.CI
? [
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
]
: []),
];
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */
export default defineConfig({ export default defineConfig({
testDir: './e2e', /* Resolves the @e2e/* and @e2e-support/* aliases that extension specs import core helpers with */
tsconfig: './tsconfig.json',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -32,23 +79,14 @@ export default defineConfig({
timeout: 20 * 1000, timeout: 20 * 1000,
/* Configure projects for major browsers */ /* Configure projects for major browsers, core specs keep the plain browser project name */
projects: [ projects: browsers.flatMap((browser) =>
{ testRoots.map((root) => ({
name: 'chromium', name: browser.name + root.suffix,
use: { ...devices['Desktop Chrome'] }, testDir: root.testDir,
}, use: browser.use,
}))
// Firefox only in CI to keep local runs fast ),
...(process.env.CI
? [
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
]
: []),
],
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
// webServer: { // webServer: {

View File

@@ -1,8 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
import { test as baseTest } from '@playwright/test'; import { test as baseTest } from '@playwright/test';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config'; import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
import { type TestContext, setupTestContext } from '../e2e/utils/api'; import { type TestContext, setupTestContext } from '../e2e/utils/api';
import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members'; import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';
import { loginAsSuperAdmin } from '../e2e/utils/admin';
export * from '@playwright/test'; export * from '@playwright/test';
export type { TestContext }; export type { TestContext };
@@ -116,3 +119,33 @@ export const test = baseTest.extend<
await closeAdmin(); await closeAdmin();
}, },
}); });
/**
* Authentication fixture for the Filament admin panel.
*
* The admin panel can only be accessed by the seeded super admin user, so the
* throwaway users of the default fixture cannot be used. Logging in is rate
* limited (5 attempts per minute and email address), therefore the session is
* created once per worker and reused by all tests of that worker.
*/
export const adminTest = baseTest.extend<object, { adminStorageState: string }>({
adminStorageState: [
async ({ browser }, use, workerInfo) => {
const fileName = path.resolve(
workerInfo.project.outputDir,
`.auth/admin-${workerInfo.workerIndex}.json`
);
if (!fs.existsSync(fileName)) {
const context = await browser.newContext({ storageState: undefined });
await loginAsSuperAdmin(await context.newPage());
await context.storageState({ path: fileName });
await context.close();
}
await use(fileName);
},
{ scope: 'worker' },
],
storageState: ({ adminStorageState }, use) => use(adminStorageState),
});

View File

@@ -1 +0,0 @@
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-DR6K5BQD.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-A2J6H3EX.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-ZABHMKQG.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-VWEHJHBA.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-ZIT2UBIY.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-DIHYUR35.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}

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