Compare commits

..

32 Commits

Author SHA1 Message Date
Gregor Vostrak
70f483b13b improve API docs 2026-10-05 16:15:27 +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
150 changed files with 3782 additions and 1273 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

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

@@ -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;
@@ -100,7 +99,7 @@ class Organization extends Model implements AuditableContract
/** /**
* 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 +132,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

@@ -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;
@@ -175,7 +174,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 +189,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

@@ -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'),
'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'),
'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_SSLMODE', 'prefer'),
], ],
'pgsql_test' => [ 'pgsql_test' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DATABASE_URL'), 'url' => env('DB_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_SSLMODE', 'prefer'),
], ],
'sqlsrv' => [ 'sqlsrv' => [
'driver' => 'sqlsrv', 'driver' => 'sqlsrv',
'url' => env('DATABASE_URL'), 'url' => env('DB_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

@@ -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.8"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
"ref": "v0.0.2" "ref": "v0.0.3"
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.6" "ref": "v0.0.7"
},
"Auditing": {
"repository": "solidtime-io/extension-auditing",
"ref": "v0.0.2"
} }
} }

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

View File

@@ -28,7 +28,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false" @submit="submit">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Client </span> <span> Create Client </span>

View File

@@ -30,7 +30,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false" @submit="submit">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Client </span> <span> Update Client </span>

View File

@@ -136,7 +136,7 @@ const roleDescription = computed(() => {
v-model:show="showOwnershipTransferConfirmModal" v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name" :member-name="member.name"
@submit="submit"></MemberOwnershipTransferConfirmModal> @submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false" @submit="saveWithChecks()">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Member </span> <span> Update Member </span>

View File

@@ -78,7 +78,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false" @submit="submit">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Invite Member </span> <span> Invite Member </span>

View File

@@ -79,7 +79,7 @@ async function submitBillableRate() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false" @submit="submit">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Edit Project {{ props.originalProject.name }} </span> <span> Edit Project {{ props.originalProject.name }} </span>

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