Compare commits

..

31 Commits

Author SHA1 Message Date
Constantin Graf
f2cdbe1e7e Fixed failing tests because of legacy currency codes 2025-07-17 18:04:56 +02:00
Constantin Graf
9158b445f1 Add email notifications for expiring api tokens 2025-07-17 17:31:37 +02:00
Constantin Graf
3d58f570bd Fixed Laravel passport migrations 2025-07-17 11:47:34 +02:00
Constantin Graf
400bc434b9 Updated docker image 2025-07-17 11:47:34 +02:00
Constantin Graf
2ab28001be Updated dependencies; Major update laravel passport 2025-07-17 11:47:34 +02:00
Gregor Vostrak
62d2f4bf4e fix broken light mode on oauth page #842 2025-07-15 15:52:55 +02:00
Gregor Vostrak
3d4b20f7c8 make sure time entry information remains visible on mobile views 2025-07-08 18:22:18 +02:00
Gregor Vostrak
155ed62fcc add clearable option to calendardateinput, fix format, add paid_date 2025-07-08 18:22:18 +02:00
Gregor Vostrak
5daa6f2a25 fix last 7 days statistic labels 2025-07-08 18:22:18 +02:00
Constantin Graf
47aa65d959 Add checks for placeholder invitation; Fixed bug in member deletion 2025-07-08 16:49:05 +02:00
Gregor Vostrak
b0e638c28b fix daterange presets, fix e2e test 2025-06-30 12:54:22 +02:00
Gregor Vostrak
24b62d4643 add information about placeholders in delete modal 2025-06-30 12:54:22 +02:00
Gregor Vostrak
dd928508fd add delete modal for member delete with relations
allow admins to delete members
fix Dialog cloes on click outside of content
2025-06-30 12:54:22 +02:00
Constantin Graf
ead9cf2185 Add option to delete members with relations 2025-06-30 12:54:22 +02:00
Gregor Vostrak
7578beb271 fix css variables not updating correctly when system theme changes 2025-06-24 15:43:49 +02:00
Constantin Graf
dc21ac8352 Switch organization after accepting invitation 2025-06-10 11:23:53 +02:00
Constantin Graf
4de7868851 Add postgres version matrix to phpunit tests 2025-06-04 21:43:35 +02:00
dependabot[bot]
ffc016a1ec Bump codecov/codecov-action from 5.4.2 to 5.4.3
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.2 to 5.4.3.
- [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/v5.4.2...v5.4.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 18:32:13 +02:00
Constantin Graf
be69626970 Add permissions to all GitHub actions 2025-05-22 11:04:37 +02:00
Gregor Vostrak
f1dce88dab fix time zone issue in daterangepicker 2025-05-21 12:34:02 -07:00
Constantin Graf
15411ec0c8 Add resend verification email to filament resource 2025-05-19 11:50:40 +02:00
Constantin Graf
48f09421d0 Fixed time entries exports for employees #2 2025-05-16 15:14:22 +02:00
Constantin Graf
36caadeb14 Fixed time entries exports for employees 2025-05-16 13:20:23 +02:00
Gregor Vostrak
b4edcaa2dc hide shared reports create for employees, fix export request for employees 2025-05-16 13:20:23 +02:00
Constantin Graf
a3dda8b03c Fixed text for clockify import 2025-05-16 13:03:47 +02:00
Constantin Graf
d64f0c52be Fixed bugs in current organization; Add database consistency checks; Add foreign key 2025-05-16 13:03:47 +02:00
Gregor Vostrak
c80d51c2e1 fix sub_group empty type placeholders showing parent type in shared reports view 2025-05-15 13:34:27 +02:00
Gregor Vostrak
46dea00b34 fix user name not displayed correctly for employee users in reporting 2025-05-15 12:54:30 +02:00
Constantin Graf
16fed4a2b7 Add base request class with generic rule sets 2025-05-14 21:07:54 +02:00
Gregor Vostrak
9a2af2e743 respect organization time format settings in api tokens section 2025-05-14 16:21:37 +02:00
Gregor Vostrak
2e3a517502 improve positioning and overflow behaviour of dialogs 2025-05-14 16:03:32 +02:00
201 changed files with 4381 additions and 2290 deletions

View File

@@ -10,6 +10,8 @@ on:
- '.github/workflows/build-private.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
contents: read
name: Build - Private
jobs:
@@ -17,6 +19,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: "Check out code"
uses: actions/checkout@v4

View File

@@ -11,6 +11,12 @@ on:
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
@@ -26,11 +32,6 @@ jobs:
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
@@ -163,11 +164,6 @@ jobs:
merge:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
needs:
- build

View File

@@ -3,6 +3,9 @@ on:
push:
branches:
- main
permissions:
contents: read
jobs:
api_docs:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: NPM Build
on: [push]
permissions:
contents: read
jobs:
build:

View File

@@ -1,6 +1,8 @@
name: NPM Lint
on: [push]
permissions:
contents: read
jobs:
build:

View File

@@ -1,6 +1,8 @@
name: Publish API package to NPM
on:
workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: Publish UI package to NPM
on:
workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,7 +1,8 @@
name: NPM Typecheck
on: [push]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Static code analysis (PHPStan)
on: push
permissions:
contents: read
jobs:
phpstan:
runs-on: ubuntu-latest

View File

@@ -1,13 +1,18 @@
name: PHPUnit Tests
on: push
permissions:
contents: read
jobs:
phpunit:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:15
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
@@ -63,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.2
uses: codecov/codecov-action@v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -1,5 +1,7 @@
name: PHP Linting
on: push
permissions:
contents: read
jobs:
pint:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Playwright Tests
on: [push]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest

View File

@@ -26,7 +26,7 @@ class CreateNewUser implements CreatesNewUsers
/**
* Create a newly registered user.
*
* @param array<string, string> $input
* @param array<string, mixed> $input
*
* @throws ValidationException
*/

View File

@@ -6,7 +6,6 @@ namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
@@ -59,8 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->updateProfilePhoto($input['photo']);
}
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
if ($input['email'] !== $user->email) {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],

View File

@@ -57,7 +57,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/
protected function rules(): array
{
return array_filter([
return [
'email' => [
'required',
'email',
@@ -75,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
Role::Employee->value,
]),
],
]);
];
}
/**

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Auth;
use App\Mail\AuthApiTokenExpirationReminderMail;
use App\Mail\AuthApiTokenExpiredMail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class AuthSendReminderForExpiringApiTokensCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'auth:send-mails-expiring-api-tokens '.
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
}
$this->comment('Sending reminder emails about expiring API tokens...');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now()->addDays(7))
->whereNull('reminder_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
$token->reminder_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
$this->comment('Sent emails about expired API tokens');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now())
->whereNull('expired_info_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpiredMail($token, $user));
$token->expired_info_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SelfHostDatabaseConsistency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:database-consistency';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$hadAProblem = false;
// Task need to be part of project in time entries
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
// Client id is the client id of the project
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('projects', 'time_entries.project_id', '=', 'projects.id')
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
->get();
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
// Client id can only be not null if the project id is not null
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->whereNotNull('client_id')
->whereNull('project_id')
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])
->leftJoin('members', 'users.id', '=', 'members.user_id')
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
// Every organization needs at least an owner
$problems = DB::table('organizations')
->select(['organizations.id as id'])
->leftJoin('members', function (JoinClause $join): void {
$join->on('organizations.id', '=', 'members.organization_id')
->where('members.role', '=', 'owner');
})
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
// Every member can only have one running time entry
$problems = DB::table('time_entries')
->select(['user_id as id'])
->whereNull('end')
->groupBy('user_id')
->havingRaw('count(*) > 1')
->get(['user_id', DB::raw('count(*) as count')]);
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
// Users have a current organization that they are not a member of
$problems = DB::table('users')
->select(['users.id as id'])
->whereNotNull('current_team_id')
->whereNotIn('current_team_id', function (Builder $query): void {
$query->select('organization_id')
->from('members')
->whereColumn('members.user_id', 'users.id');
})->get();
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
return $hadAProblem ? self::FAILURE : self::SUCCESS;
}
/**
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{
$message = 'Consistency problem: '.$message;
if ($problems->isNotEmpty()) {
$ids = $problems->pluck('id');
$hadAProblem = true;
Log::error($message, [
'ids' => $ids,
]);
$error = $message;
foreach ($ids as $id) {
$error .= "\n - ".$id;
}
$this->error($error);
}
}
}

View File

@@ -18,6 +18,10 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes();
$schedule->command('auth:send-mails-expiring-api-tokens')
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();
@@ -25,6 +29,10 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->everySixHours();
}
/**

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederAfterSeed
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederBeforeDelete
{
use Dispatchable;
public function __construct() {}
}

View File

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

View File

@@ -41,9 +41,7 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null;
}
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
return null;
}
$collectingType = $this->openApiTransformer->transform($collectingClassType);
$newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));

View File

@@ -15,6 +15,7 @@ use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -75,7 +76,8 @@ class FailedJobResource extends Resource
->filters([])
->bulkActions([
BulkAction::make('retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->label('Retry selected')
->requiresConfirmation()
->action(function (Collection $records): void {
/** @var FailedJob $record */
@@ -87,11 +89,13 @@ class FailedJobResource extends Resource
->success()
->send();
}),
DeleteBulkAction::make(),
])
->actions([
DeleteAction::make('Delete'),
ViewAction::make('View'),
DeleteAction::make(),
ViewAction::make(),
Action::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry')
->requiresConfirmation()
->action(function (FailedJob $record): void {
@@ -109,7 +113,6 @@ class FailedJobResource extends Resource
return [
'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'),
];
}
}

View File

@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
@@ -19,7 +19,8 @@ class ListFailedJobs extends ListRecords
{
return [
Action::make('retry_all')
->label('Retry all failed Jobs')
->icon('heroicon-o-arrow-path')
->label('Retry all')
->requiresConfirmation()
->action(function (): void {
Artisan::call('queue:retry all');
@@ -30,7 +31,8 @@ class ListFailedJobs extends ListRecords
}),
Action::make('delete_all')
->label('Delete all failed Jobs')
->icon('heroicon-o-trash')
->label('Delete all')
->requiresConfirmation()
->color('danger')
->action(function (): void {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
@@ -40,7 +39,7 @@ class TokenResource extends Resource
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('user_id')
Forms\Components\Select::make('owner_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
@@ -79,10 +78,12 @@ class TokenResource extends Resource
Tables\Columns\TextColumn::make('client.name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('client.personal_access_client')
Tables\Columns\IconColumn::make('personal_access_client')
->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true);
})
->boolean()
->label('API token?')
->sortable(),
->label('API token?'),
Tables\Columns\IconColumn::make('revoked')
->boolean()
->label('Revoked?')
@@ -104,17 +105,11 @@ class TokenResource extends Resource
->queries(
true: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', true);
});
return $query->isApiToken();
},
false: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', false);
});
return $query->isApiToken(false);
},
blank: function (Builder $query) {
/** @var Builder<Token> $query */

View File

@@ -23,6 +23,7 @@ use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -207,6 +208,14 @@ class UserResource extends Resource
}),
])
->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
/** @var User $user */
$user->sendEmailVerificationNotification();
}
}),
]);
}

View File

@@ -8,9 +8,12 @@ use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
class ApiTokenController extends Controller
{
@@ -28,7 +31,10 @@ class ApiTokenController extends Controller
$user = $this->user();
$tokens = $user->tokens()
->where('client_id', '=', config('passport.personal_access_client.id'))
->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->get();
return new ApiTokenCollection($tokens);
@@ -48,15 +54,21 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
try {
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->getToken();
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
} catch (\RuntimeException $exception) {
report($exception);
if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {
throw new PersonalAccessClientIsNotConfiguredException;
}
throw $exception;
}
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
}
/**
@@ -71,13 +83,10 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
throw new AuthorizationException('API token is not a personal access token');
}
@@ -97,13 +106,10 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
throw new AuthorizationException('API token is not a personal access token');
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
@@ -50,6 +51,7 @@ class InvitationController extends Controller
*
* @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invite
*/

View File

@@ -10,12 +10,14 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberDestroyRequest;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
@@ -100,11 +102,13 @@ class MemberController extends Controller
*
* @operationId removeMember
*/
public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:delete', $member);
$memberService->removeMember($member, $organization);
$deleteRelated = $request->getDeleteRelated();
$memberService->removeMember($member, $organization, $deleteRelated);
return response()
->json(null, 204);
@@ -170,6 +174,7 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invitePlaceholder
*/

View File

@@ -226,6 +226,7 @@ class TimeEntryController extends Controller
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -428,6 +429,7 @@ class TimeEntryController extends Controller
'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug,
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -456,7 +458,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
$path,
config('filesystems.private'),
$format->getExportPackageType(),

View File

@@ -43,7 +43,10 @@ class Controller extends BaseController
/** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
throw new AuthorizationException;
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\URL;
@@ -20,8 +19,7 @@ class EnsureEmailIsVerified
{
if (! app()->isLocal()) {
if ($request->user() === null ||
($request->user() instanceof MustVerifyEmail &&
! $request->user()->hasVerifiedEmail())) {
(! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()
? abort(403, 'Your email address is not verified.')
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));

View File

@@ -50,7 +50,7 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $billing !== null && $currentOrganization !== null ? [
'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),

View File

@@ -26,7 +26,7 @@ class ShareInertiaData
{
/** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class);
Inertia::share(array_filter([
Inertia::share([
'jetstream' => function () use ($request) {
/** @var User|null $user */
$user = $request->user();
@@ -101,7 +101,7 @@ class ShareInertiaData
return [$key => $bag->messages()];
})->all();
},
]));
]);
return $next($request);
}

View File

@@ -8,9 +8,7 @@ use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
/**
* @param bool $bigInt
* @return list<string>
*/
protected function moneyRules(bool $bigInt = false): array

View File

@@ -7,11 +7,8 @@ namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization
@@ -29,10 +26,6 @@ class InvitationStoreRequest extends BaseFormRequest
'email' => [
'required',
'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'),
],
'role' => [
'required',

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* @property Organization $organization
*/
class MemberDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'delete_related' => [
'string',
'in:true,false',
],
];
}
public function getDeleteRelated(): bool
{
return $this->input('delete_related', 'false') === 'true';
}
}

View File

@@ -8,15 +8,11 @@ use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{
private bool $showBillableRates;
/**
* @param LengthAwarePaginator<Project> $resource
*/
public function __construct($resource, bool $showBillableRates)
{
parent::__construct($resource);

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpirationReminderMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-expiration-reminder', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token will expire in 7 days!'));
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpiredMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-token-expired', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token has expired!'));
}
}

View File

@@ -16,8 +16,8 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
* @property array|null $old_values
* @property array|null $new_values
* @property array<string, mixed>|null $old_values
* @property array<string, mixed>|null $new_values
* @property string|null $url
* @property string|null $ip_address
* @property string|null $user_agent

View File

@@ -47,7 +47,7 @@ class Client extends Model implements AuditableContract
];
/**
* @return BelongsTo<Organization, Client>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -55,7 +55,7 @@ class Client extends Model implements AuditableContract
}
/**
* @return HasMany<Project>
* @return HasMany<Project, $this>
*/
public function projects(): HasMany
{

View File

@@ -25,8 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read User $user
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries
*
* @method static MemberFactory factory()
*/
@@ -47,7 +47,7 @@ class Member extends JetstreamMembership implements AuditableContract
protected $table = 'members';
/**
* @return BelongsTo<User, Member>
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
@@ -55,7 +55,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return BelongsTo<Organization, Member>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -63,7 +63,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<TimeEntry>
* @return HasMany<TimeEntry, $this>
*/
public function timeEntries(): HasMany
{
@@ -71,7 +71,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<ProjectMember>
* @return HasMany<ProjectMember, $this>
*/
public function projectMembers(): HasMany
{

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated;
@@ -47,7 +48,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property IntervalFormat $interval_format
* @property TimeFormat $time_format
*
* @method HasMany<OrganizationInvitation> teamInvitations()
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam implements AuditableContract
@@ -79,7 +80,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'name',
@@ -125,7 +126,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get all the users that belong to the team.
*
* @return BelongsToMany<User>
* @return BelongsToMany<User, $this, Pivot, 'membership'>
*/
public function users(): BelongsToMany
{
@@ -142,7 +143,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get the owner of the team.
*
* @return BelongsTo<User, Organization>
* @return BelongsTo<User, $this>
*/
public function owner(): BelongsTo
{
@@ -150,7 +151,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return HasMany<Member>
* @return HasMany<Member, $this>
*/
public function members(): HasMany
{
@@ -158,7 +159,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return BelongsToMany<User>
* @return BelongsToMany<User, $this, Pivot, 'membership'>
*/
public function realUsers(): BelongsToMany
{

View File

@@ -53,7 +53,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, OrganizationInvitation>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -63,7 +63,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, OrganizationInvitation>
* @return BelongsTo<Organization, $this>
*/
public function team(): BelongsTo
{

View File

@@ -4,6 +4,26 @@ declare(strict_types=1);
namespace App\Models\Passport;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\AuthCode as PassportAuthCode;
class AuthCode extends PassportAuthCode {}
/**
* @property string $id
* @property string $user_id
* @property string $client_id
* @property string|null $scopes
* @property bool $revoked
* @property Carbon $expires_at
*/
class AuthCode extends PassportAuthCode
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -5,22 +5,36 @@ declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use Laravel\Passport\Client as PassportClient;
/**
* @property string $id
* @property string|null $user_id
* @property string|null $owner_id
* @property string|null $owner_type
* @property string $name
* @property string|null $secret
* @property string|null $provider
* @property string $redirect
* @property bool $personal_access_client
* @property bool $password_client
* @property array<string> $grant_types
* @property array<string> $redirect_uris
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property bool $revoked
*/
class Client extends PassportClient
{
/** @use HasFactory<ClientFactory> */
use HasFactory;
/**
* Create a new factory instance for the model.
*
* @return ClientFactory
*/
protected static function newFactory(): Factory
{
return ClientFactory::new();
}
}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
class PersonalAccessClient extends PassportPersonalAccessClient {}

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Models\Passport;
use App\Models\User;
use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
@@ -17,9 +19,15 @@ use Laravel\Passport\Token as PassportToken;
* @property null|string $name
* @property array<string> $scopes
* @property bool $revoked
* @property Carbon|null $reminder_sent_at
* @property Carbon|null $expired_info_sent_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $expires_at
* @property-read Client|null $client
* @property-read User|null $user
*
* @method Builder<Token> isApiToken(bool $isApiToken = true)
*/
class Token extends PassportToken
{
@@ -29,10 +37,60 @@ class Token extends PassportToken
/**
* Get the client that the token belongs to.
*
* @return BelongsTo<Client, Token>
* @return BelongsTo<Client, $this>
*/
// @phpstan-ignore method.childReturnType
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id', 'id');
}
/**
* Get the user that the token belongs to.
*
* @deprecated Will be removed in a future Laravel version.
*
* @return BelongsTo<User, $this>
*/
// @phpstan-ignore method.childReturnType
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'scopes' => 'array',
'revoked' => 'bool',
'expires_at' => 'datetime',
'reminder_sent_at' => 'datetime',
'expired_info_sent_at' => 'datetime',
];
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
{
if ($isApiToken) {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
});
} else {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonDoesntContain('grant_types', 'personal_access');
});
}
}
}

View File

@@ -137,7 +137,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return BelongsTo<Organization, Project>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -145,7 +145,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return BelongsTo<Client, Project>
* @return BelongsTo<Client, $this>
*/
public function client(): BelongsTo
{
@@ -153,7 +153,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<ProjectMember>
* @return HasMany<ProjectMember, $this>
*/
public function members(): HasMany
{
@@ -161,7 +161,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<Task>
* @return HasMany<Task, $this>
*/
public function tasks(): HasMany
{
@@ -169,7 +169,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<TimeEntry>
* @return HasMany<TimeEntry, $this>
*/
public function timeEntries(): HasMany
{

View File

@@ -48,7 +48,7 @@ class ProjectMember extends Model implements AuditableContract
];
/**
* @return BelongsTo<Project, ProjectMember>
* @return BelongsTo<Project, $this>
*/
public function project(): BelongsTo
{
@@ -58,7 +58,7 @@ class ProjectMember extends Model implements AuditableContract
/**
* @deprecated Use member relationship instead
*
* @return BelongsTo<User, ProjectMember>
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
@@ -66,7 +66,7 @@ class ProjectMember extends Model implements AuditableContract
}
/**
* @return BelongsTo<Member, ProjectMember>
* @return BelongsTo<Member, $this>
*/
public function member(): BelongsTo
{

View File

@@ -55,7 +55,7 @@ class Report extends Model
}
/**
* @return BelongsTo<Organization, Report>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{

View File

@@ -22,7 +22,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
* @property string $organization_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Organization $organization
*
* @method static TagFactory factory()
@@ -47,7 +47,7 @@ class Tag extends Model implements AuditableContract
];
/**
* @return BelongsTo<Organization, Tag>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{

View File

@@ -120,7 +120,7 @@ class Task extends Model implements AuditableContract
}
/**
* @return BelongsTo<Project, Task>
* @return BelongsTo<Project, $this>
*/
public function project(): BelongsTo
{
@@ -128,7 +128,7 @@ class Task extends Model implements AuditableContract
}
/**
* @return BelongsTo<Organization, Task>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -136,7 +136,7 @@ class Task extends Model implements AuditableContract
}
/**
* @return HasMany<TimeEntry>
* @return HasMany<TimeEntry, $this>
*/
public function timeEntries(): HasMany
{

View File

@@ -28,7 +28,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable
* @property array $tags
* @property array<string> $tags
* @property string $user_id
* @property string $member_id
* @property bool $is_imported
@@ -45,7 +45,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property-read Client|null $client
* @property string|null $task_id
* @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation
* @property-read Collection<int, Tag> $tagsRelation
*
* @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory()
@@ -154,7 +154,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<User, TimeEntry>
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
@@ -162,7 +162,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Member, TimeEntry>
* @return BelongsTo<Member, $this>
*/
public function member(): BelongsTo
{
@@ -170,7 +170,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Organization, TimeEntry>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -178,7 +178,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Project, TimeEntry>
* @return BelongsTo<Project, $this>
*/
public function project(): BelongsTo
{
@@ -186,7 +186,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Task, TimeEntry>
* @return BelongsTo<Task, $this>
*/
public function task(): BelongsTo
{
@@ -196,7 +196,7 @@ class TimeEntry extends Model implements AuditableContract
/**
* This relation can be reconstructed via the task relation. It is only here for performance reasons.
*
* @return BelongsTo<Client, TimeEntry>
* @return BelongsTo<Client, $this>
*/
public function client(): BelongsTo
{

View File

@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
@@ -27,6 +28,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -52,13 +54,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership
*
* @method HasMany<Organization> ownedTeams()
* @method HasMany<Organization, $this> ownedTeams()
* @method static UserFactory factory()
* @method static Builder<User> query()
* @method Builder<User> belongsToOrganization(Organization $organization)
* @method Builder<User> active()
*/
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail, OAuthenticatable
{
use CustomAuditable;
use HasApiTokens;
@@ -75,7 +77,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'name',
@@ -86,7 +88,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
* @var list<string>
*/
protected $hidden = [
'password',
@@ -143,7 +145,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsToMany<Organization>
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
*/
public function organizations(): BelongsToMany
{
@@ -158,7 +160,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<TimeEntry>
* @return HasMany<TimeEntry, $this>
*/
public function timeEntries(): HasMany
{
@@ -166,7 +168,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsTo<Organization, User>
* @return BelongsTo<Organization, $this>
*/
public function currentOrganization(): BelongsTo
{
@@ -174,7 +176,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<ProjectMember>
* @return HasMany<ProjectMember, $this>
*/
public function projectMembers(): HasMany
{
@@ -182,7 +184,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<Token>
* @return HasMany<Token, $this>
*/
public function accessTokens(): HasMany
{
@@ -190,24 +192,13 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<AuthCode>
* @return HasMany<AuthCode, $this>
*/
public function authCodes(): HasMany
{
return $this->hasMany(AuthCode::class);
}
/**
* Get the access tokens for the user.
*
* @return HasMany<Token>
*/
public function tokens(): HasMany
{
return $this->hasMany(Token::class, 'user_id')
->orderBy('created_at', 'desc');
}
/**
* @param Builder<User> $builder
*/

View File

@@ -7,7 +7,6 @@ namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\PersonalAccessClient;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
@@ -51,7 +50,8 @@ class AuthServiceProvider extends ServiceProvider
Passport::useRefreshTokenModel(RefreshToken::class);
Passport::useAuthCodeModel(AuthCode::class);
Passport::useClientModel(Client::class);
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
Passport::authorizationView('auth.oauth.authorize');
// Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30));

View File

@@ -187,6 +187,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder',
'members:make-placeholder',
'members:merge-into',
'members:delete',
'members:update',
'reports:view',
'reports:create',

View File

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

View File

@@ -100,12 +100,18 @@ class DeletionService
// Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) {
/** @var User $user */
if ($ignoreUser !== null && $user->is($ignoreUser)) {
continue;
}
if ($user->is_placeholder) {
$user->delete();
} else {
if ($user->current_team_id === $organization->getKey()) {
$user->currentOrganization()->disassociate();
$user->save();
}
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}

View File

@@ -119,9 +119,6 @@ class ReportPropertiesDto implements Castable
return $dto;
}
/**
* @param ReportPropertiesDto $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof ReportPropertiesDto)) {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail;
use App\Models\Member;
@@ -16,7 +17,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService
{
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{
@@ -28,6 +29,13 @@ class InvitationService
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
if (OrganizationInvitation::query()
->where('email', $email)
->whereBelongsTo($organization, 'organization')
->exists()) {
throw new InvitationForTheEmailAlreadyExistsApiException;
}
InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation;

View File

@@ -45,6 +45,9 @@ class MemberService
$member->organization()->associate($organization);
$member->role = $role->value;
$member->save();
$user->currentOrganization()->associate($organization);
$user->save();
});
if (! $asSuperAdmin) {
@@ -58,19 +61,41 @@ class MemberService
* @throws CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException
*/
public function removeMember(Member $member, Organization $organization): void
public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void
{
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$user = $member->user;
$isPlaceholder = $user->is_placeholder;
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
if ($withRelations) {
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
} else {
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
}
$member->delete();
if ($isPlaceholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
MemberRemoved::dispatch($member, $organization);
}
@@ -164,6 +189,11 @@ class MemberService
public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void
{
$user = $member->user;
if ($user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->save();
@@ -175,6 +205,7 @@ class MemberService
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
}
}

View File

@@ -71,7 +71,7 @@ class PermissionStore
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
return $roleObj?->permissions ?? [];
return $roleObj->permissions ?? [];
}
/**

View File

@@ -46,6 +46,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private TimeEntryAggregationType $subGroup;
private bool $showBillableRate;
/**
* @param array{
* grouped_type: string|null,
@@ -66,13 +68,14 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
$this->currency = $currency;
$this->group = $group;
$this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
}
public function view(): View
@@ -83,6 +86,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'group' => $this->group,
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
]);
}

View File

@@ -114,13 +114,15 @@ class UserService
public function makeSureUserHasCurrentOrganization(User $user): void
{
if ($user->currentOrganization !== null) {
if ($user->current_team_id !== null) {
return;
}
$organization = $user->organizations()->first();
$user->currentOrganization()->associate($organization);
$user->save();
if ($organization !== null) {
$user->currentOrganization()->associate($organization);
$user->save();
}
}
/**

View File

@@ -11,31 +11,31 @@
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.3.0",
"flowframe/laravel-trend": "^0.4.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0",
"inertiajs/inertia-laravel": "^2.0.3",
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
"laravel/framework": "^11.16.0",
"laravel/framework": "^12.19.3",
"laravel/jetstream": "^5.0",
"laravel/octane": "^2.3",
"laravel/passport": "^12.0",
"laravel/passport": "^13.0.5",
"laravel/tinker": "^2.8",
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.0",
"nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^14.0.0",
"pxlrbt/filament-environment-indicator": "^2.1.0",
"spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^2.0.0",
"tpetry/laravel-postgresql-enhanced": "^3.0.0",
"wikimedia/composer-merge-plugin": "^2.1.0"
},
"require-dev": {
@@ -43,14 +43,13 @@
"brianium/paratest": "^7.3",
"fakerphp/faker": "^1.9.1",
"fumeapp/modeltyper": "^3.0",
"phpstan/phpstan": "1.12.0",
"larastan/larastan": "^2.0",
"larastan/larastan": "^3.5.0",
"laravel/pint": "^1.0",
"laravel/sail": "^1.18",
"laravel/telescope": "^5.0",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^11",
"phpunit/phpunit": "^12",
"spatie/laravel-ignition": "^2.0",
"timacdonald/log-fake": "^2.1"
},

2458
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,31 +34,15 @@ return [
/*
|--------------------------------------------------------------------------
| Client UUIDs
| Passport Database Connection
|--------------------------------------------------------------------------
|
| By default, Passport uses auto-incrementing primary keys when assigning
| IDs to clients. However, if Passport is installed using the provided
| --uuids switch, this will be set to "true" and UUIDs will be used.
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
|
*/
'client_uuids' => true,
/*
|--------------------------------------------------------------------------
| Personal Access Client
|--------------------------------------------------------------------------
|
| If you enable client hashing, you should set the personal access client
| ID and unhashed secret within your environment file. The values will
| get used while issuing fresh personal access tokens to your users.
|
*/
'personal_access_client' => [
'id' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_ID'),
'secret' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET'),
],
'connection' => env('PASSPORT_CONNECTION'),
];

View File

@@ -6,7 +6,9 @@ return [
'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
],
];

View File

@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Models\Organization;
use App\Models\User;
use App\Service\CurrencyService;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@@ -27,7 +28,7 @@ class OrganizationFactory extends Factory
{
return [
'name' => $this->faker->unique()->company(),
'currency' => $this->faker->currencyCode(),
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),
'billable_rate' => null,
'user_id' => User::factory(),
'personal_team' => true,

View File

@@ -7,11 +7,12 @@ namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Passport\Database\Factories\ClientFactory as BaseClientFactory;
/**
* @extends Factory<Client>
*/
class ClientFactory extends Factory
class ClientFactory extends BaseClientFactory
{
/**
* Define the model's default state.
@@ -22,24 +23,40 @@ class ClientFactory extends Factory
{
return [
'id' => $this->faker->uuid,
'user_id' => null,
'owner_id' => null,
'owner_type' => null,
'name' => $this->faker->company(),
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
'provider' => 'users',
'redirect' => $this->faker->url(),
'personal_access_client' => false,
'password_client' => false,
'redirect_uris' => [$this->faker->url()],
'grant_types' => [],
'revoked' => false,
'created_at' => $this->faker->dateTime(),
'updated_at' => $this->faker->dateTime(),
];
}
public function desktopClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'Desktop',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],
]);
}
public function apiClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'API',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],
]);
}
public function personalAccessClient(): self
{
return $this->state(function (array $attributes) {
return [
'personal_access_client' => true,
'grant_types' => ['personal_access'],
];
});
}
@@ -48,7 +65,8 @@ class ClientFactory extends Factory
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
'owner_id' => $user->getKey(),
'owner_type' => (new User)->getMorphClass(),
];
});
}

View File

@@ -31,6 +31,8 @@ class TokenFactory extends Factory
'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime,
'expires_at' => $this->faker->dateTime,
'reminder_sent_at' => null,
'expired_info_sent_at' => null,
];
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table): void {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement('
update users
set current_team_id = null
where id in (
select users.id from users
left join organizations on users.current_team_id = organizations.id
where users.current_team_id is not null and organizations.id is null
)
');
Schema::table('users', function (Blueprint $table): void {
$table->foreign('current_team_id', 'organizations_current_organization_id_foreign')
->references('id')
->on('organizations')
->onDelete('restrict')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropForeign('organizations_current_organization_id_foreign');
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::drop('oauth_personal_access_clients');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('client_id');
$table->foreign('client_id')
->references('id')
->on('oauth_clients')
->onDelete('restrict')
->onUpdate('cascade');
$table->timestamps();
});
}
};

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('oauth_clients')->update(['provider' => 'users']); // Change default provider if necessary
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->text('grant_types')->default('[]')->after('provider');
$table->text('redirect_uris')->default('[]');
$table->renameColumn('user_id', 'owner_id');
$table->string('owner_type')->after('owner_id')->nullable();
});
DB::table('oauth_clients')
->where('redirect', '=', 'http://localhost')
->where('personal_access_client', '=', true)
->update(['redirect' => '']);
DB::table('oauth_clients')
->whereNotNull('owner_id')
->update(['owner_type' => 'user']); // Value might be class name of the owner model, depends on if you use "enforceMorphMap"
DB::table('oauth_clients')->eachById(function ($client): void {
$grantTypes = ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'];
$confidential = ! empty($client->secret);
$noRedirect = empty($client->redirect);
$redirectUris = $noRedirect ? [] : [$client->redirect];
$firstParty = empty($client->owner_id);
if (! $noRedirect) {
$grantTypes[] = 'authorization_code';
$grantTypes[] = 'implicit';
}
if ($confidential && $firstParty) {
$grantTypes[] = 'client_credentials';
}
if ($client->personal_access_client && $confidential) {
$grantTypes[] = 'personal_access';
}
if ($client->password_client) {
$grantTypes[] = 'password';
}
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'redirect_uris' => $redirectUris,
'grant_types' => $grantTypes,
]);
});
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->index(['owner_id', 'owner_type']);
$table->dropColumn('redirect');
$table->dropColumn('personal_access_client');
$table->dropColumn('password_client');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropIndex(['owner_id', 'owner_type']);
$table->renameColumn('owner_id', 'user_id');
$table->foreign('user_id')
->on('users')
->references('id')
->onDelete('cascade')
->onUpdate('cascade');
$table->string('redirect')->nullable();
$table->boolean('personal_access_client')->default(false);
$table->boolean('password_client')->default(false);
});
DB::table('oauth_clients')->eachById(function ($client): void {
$redirectUris = json_decode($client->redirect_uris);
$grantTypes = json_decode($client->grant_types);
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'redirect' => $redirectUris[0] ?? '', // redirect not nullable
'password_client' => in_array('password', $grantTypes, true)
&& in_array('refresh_token', $grantTypes, true),
'personal_access_client' => in_array('personal_access', $grantTypes, true),
]);
});
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropColumn(['grant_types', 'redirect_uris', 'owner_type']);
$table->string('redirect')->nullable(false)->change();
$table->boolean('personal_access_client')->default(null)->change();
$table->boolean('password_client')->default(null)->change();
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// This could be optimized to run all the updates in the eachById
DB::table('oauth_clients')->whereNotNull('secret')->eachById(function ($client): void {
$secret = $client->secret;
if (Hash::isHashed($secret) && ! Hash::needsRehash($secret)) {
return; // Already hashed and not needing rehash
}
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'secret' => Hash::make($secret),
]);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// This can not be reversed without a backup of the original secrets, for security reasons.
}
};

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
$table->dateTime('reminder_sent_at')->nullable();
$table->dateTime('expired_info_sent_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
$table->dropColumn('reminder_sent_at');
$table->dropColumn('expired_info_sent_at');
});
}
};

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\Role;
use App\Events\DatabaseSeederAfterSeed;
use App\Events\DatabaseSeederBeforeDelete;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Member;
@@ -22,7 +24,6 @@ use Illuminate\Support\Facades\DB;
use Laravel\Passport\AuthCode;
use Laravel\Passport\Client as PassportClient;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\PersonalAccessClient;
use Laravel\Passport\RefreshToken;
use Laravel\Passport\Token;
@@ -35,6 +36,18 @@ class DatabaseSeeder extends Seeder
{
$this->deleteAll();
app(ClientRepository::class)->createAuthorizationCodeGrantClient(
name: 'Desktop App',
redirectUris: ['solidtime://oauth/callback'],
confidential: false, // TODO: ?
enableDeviceFlow: false, // TODO: ?
);
// TODO: grant_types ? migration?
// app(ClientRepository::class)->createPersonalAccessGrantClient('API');
/*
app(ClientRepository::class)->create(
null,
'desktop',
@@ -44,17 +57,16 @@ class DatabaseSeeder extends Seeder
false,
false
);
*/
$personalAccessClient = new PassportClient;
$personalAccessClient->id = config('passport.personal_access_client.id');
$personalAccessClient->secret = config('passport.personal_access_client.secret');
$personalAccessClient->name = 'API';
$personalAccessClient->redirect = 'http://localhost';
$personalAccessClient->user_id = null;
$personalAccessClient->redirect_uris = ['http://localhost'];
$personalAccessClient->revoked = false;
$personalAccessClient->provider = null;
$personalAccessClient->personal_access_client = true;
$personalAccessClient->password_client = false;
$personalAccessClient->provider = 'users';
$personalAccessClient->grant_types = ['personal_access'];
$personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
@@ -184,15 +196,17 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com',
]);
DatabaseSeederAfterSeed::dispatch();
}
private function deleteAll(): void
{
DatabaseSeederBeforeDelete::dispatch();
// Laravel Passport tables
DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete();
DB::table((new AuthCode)->getTable())->delete();
DB::table((new PersonalAccessClient)->getTable())->delete();
DB::table((new PassportClient)->getTable())->delete();
// Internal tables
@@ -213,6 +227,9 @@ class DatabaseSeeder extends Seeder
DB::table((new Client)->getTable())->delete();
DB::table((new Member)->getTable())->delete();
DB::table((new OrganizationInvitation)->getTable())->delete();
DB::table((new User)->getTable())->update([
'current_team_id' => null,
]);
DB::table((new Organization)->getTable())->delete();
DB::table((new User)->getTable())->delete();
}

View File

@@ -1,47 +1,30 @@
# Accepted values: 8.3 - 8.2
ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=latest
ARG COMPOSER_VERSION=latest
ARG FRANKENPHP_VERSION=1.8
ARG COMPOSER_VERSION=2.8
ARG BUN_VERSION="latest"
ARG APP_ENV
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
###########################################
# Build frontend assets with NPM
###########################################
#ARG NODE_VERSION=20-alpine
#
#FROM node:${NODE_VERSION} AS build
#
#ENV ROOT=/var/www/html
#
#WORKDIR ${ROOT}
#
#RUN npm config set update-notifier false && npm set progress=false
#
#COPY package*.json ./
#
#RUN if [ -f $ROOT/package-lock.json ]; \
# then \
# npm ci --loglevel=error --no-audit; \
# else \
# npm install --loglevel=error --no-audit; \
# fi
#
#COPY . .
#
#RUN npm run build
###########################################
FROM composer:${COMPOSER_VERSION} AS vendor
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION} AS upstream
ARG DOCKER_FILES_BASE_PATH
ARG TARGETPLATFORM
COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy
RUN CGO_ENABLED=1 \
XCADDY_SETCAP=1 \
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
--with github.com/dunglas/caddy-cbrotli
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
LABEL maintainer="solidtime <hello@solidtime.io>"
LABEL org.opencontainers.image.title="solidtime"
@@ -53,18 +36,22 @@ ARG WWWUSER=1000
ARG WWWGROUP=1000
ARG TZ=UTC
ARG APP_DIR=/var/www/html
ARG APP_ENV
ARG APP_HOST
ARG DOCKER_FILES_BASE_PATH
ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=frankenphp \
TZ=${TZ} \
USER=octane \
ROOT=${APP_DIR} \
APP_ENV=${APP_ENV} \
COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24 \
XDG_CONFIG_HOME=${APP_DIR}/.config \
XDG_DATA_HOME=${APP_DIR}/.data
XDG_DATA_HOME=${APP_DIR}/.data \
SERVER_NAME=${APP_HOST}
WORKDIR ${ROOT}
@@ -78,14 +65,16 @@ RUN apt-get update; \
apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \
curl \
gcc \
wget \
nano \
vim \
git \
ncdu \
procps \
unzip \
ca-certificates \
supervisor \
libsodium-dev \
libbrotli-dev \
# Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \
bz2 \
@@ -99,6 +88,8 @@ RUN apt-get update; \
exif \
pdo_mysql \
zip \
uv \
vips \
intl \
gd \
redis \
@@ -128,27 +119,34 @@ RUN arch="$(uname -m)" \
RUN userdel --remove --force www-data \
&& groupadd --force -g ${WWWGROUP} ${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 ${WWWGROUP} -u ${WWWUSER} ${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
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane-default.ini
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php-arm.ini ${PHP_INI_DIR}/conf.d/99-octane-arm.ini
RUN echo "TARGETPLATFORM is equal to ${TARGETPLATFORM}"
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
rm ${PHP_INI_DIR}/conf.d/99-octane-default.ini; \
else \
rm ${PHP_INI_DIR}/conf.d/99-octane-arm.ini; \
fi
USER ${USER}
COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
#COPY --chown=${USER}:${USER} composer.json composer.lock ./
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 \
# --no-dev \
@@ -158,22 +156,47 @@ COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
# --no-scripts \
# --audit
COPY --chown=${USER}:${USER} . .
#COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
###########################################
# 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* ./
#
#RUN bun install --frozen-lockfile
#
#COPY --link . .
#COPY --link --from=common ${ROOT}/vendor vendor
#
#RUN bun run build
###########################################
#FROM common AS runner
USER ${USER}
ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/supervisor/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
# FrankenPHP embedded PHP configuration
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/php.ini
#RUN composer install \
# --classmap-authoritative \
# --no-interaction \
@@ -183,12 +206,9 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
RUN cat .env
#RUN php artisan env
RUN php artisan storage:link
RUN chmod +x /usr/local/bin/start-container
RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
EXPOSE 8000
ENTRYPOINT ["start-container"]
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env sh
set -e
container_mode=${CONTAINER_MODE:-"http"}
if [ "${container_mode}" = "http" ]; then
php "${ROOT}/artisan" octane:status
elif [ "${container_mode}" = "horizon" ]; then
php "${ROOT}/artisan" horizon:status
elif [ "${container_mode}" = "scheduler" ]; then
if [ "$(supervisorctl status scheduler:scheduler_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "reverb" ]; then
if [ "$(supervisorctl status reverb:reverb_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "worker" ]; then
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
else
echo "Container mode mismatched."
exit 1
fi

View File

@@ -0,0 +1,68 @@
{
{$CADDY_GLOBAL_OPTIONS}
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
frankenphp {
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
}
metrics {
per_host
}
servers {
protocols h1
}
}
{$CADDY_EXTRA_CONFIG}
{$CADDY_SERVER_SERVER_NAME} {
log {
level WARN
format filter {
wrap {$CADDY_SERVER_LOGGER}
fields {
uri query {
replace authorization REDACTED
}
}
}
}
route {
root * "{$APP_PUBLIC_PATH}"
encode zstd br gzip
{$CADDY_SERVER_EXTRA_DIRECTIVES}
request_body {
max_size 500MB
}
@static {
file
path *.js *.css *.jpg *.jpeg *.webp *.weba *.webm *.gif *.png *.ico *.cur *.gz *.svg *.svgz *.mp4 *.mp3 *.ogg *.ogv *.htc *.woff2 *.woff
}
@staticshort {
file
path *.json *.xml *.rss
}
header @static Cache-Control "public, immutable, stale-while-revalidate, max-age=31536000"
header @staticshort Cache-Control "no-cache, max-age=3600"
@rejected `path('*.bak', '*.conf', '*.dist', '*.fla', '*.ini', '*.inc', '*.inci', '*.log', '*.orig', '*.psd', '*.sh', '*.sql', '*.swo', '*.swp', '*.swop', '*/.*') && !path('*/.well-known/*')`
error @rejected 401
php_server {
index frankenphp-worker.php
try_files {path} frankenphp-worker.php
resolve_root_symlink
}
}
}

View File

@@ -1,51 +1,65 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019
; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
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
user = %(ENV_USER)s
priority = 1
autostart = true
autorestart = true
environment = LARAVEL_OCTANE = "1"
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
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
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)02d
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
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)02d
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
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]
files=/etc/supervisor/supervisord.conf
files = /etc/supervisord.conf

View File

@@ -1,25 +0,0 @@
version: '2.7'
rpc:
listen: 'tcp://127.0.0.1:6001'
server:
relay: pipes
http:
middleware: [ "static", "gzip", "headers" ]
max_request_size: 20
static:
dir: "public"
forbid: [ ".php", ".htaccess" ]
uploads:
forbid: [".php", ".exe", ".bat", ".sh"]
pool:
allocate_timeout: 10s
destroy_timeout: 10s
supervisor:
max_worker_memory: 128
exec_ttl: 60s
logs:
mode: production
level: debug
encoding: json
status:
address: localhost:2114

View File

@@ -1,50 +0,0 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000 --rpc-port=6001 --rr-config=%(ENV_ROOT)s/.rr.yaml
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
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)02d
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)02d
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
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -1,50 +0,0 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
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)02d
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)02d
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
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -1,30 +0,0 @@
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
expose_php = 0
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
[Opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256M
opcache.use_cwd = 0
opcache.max_file_size = 0
opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16
opcache.file_cache = 60
[JIT]
opcache.jit_buffer_size = 128M
opcache.jit = disable
opcache.jit_prof_threshold = 0.001
opcache.jit_max_root_traces = 2048
opcache.jit_max_side_traces = 256
[zlib]
zlib.output_compression = On
zlib.output_compression_level = 9

View File

@@ -5,6 +5,8 @@ expose_php = 0
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
register_argc_argv = 0
date.timezone = ${TZ:-UTC}
[Opcache]
opcache.enable = 1
@@ -16,7 +18,6 @@ opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16
opcache.file_cache = 60
[JIT]
opcache.jit_buffer_size = 128M

View File

@@ -4,41 +4,49 @@ set -e
container_mode=${CONTAINER_MODE:-"http"}
octane_server=${OCTANE_SERVER}
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
echo "Container mode: $container_mode"
initialStuff() {
echo "Container mode: $container_mode"
if [ ${auto_db_migrate} = "true" ]; then
echo "Auto database migration enabled."
php artisan migrate --isolated --force
fi
php artisan storage:link; \
php artisan optimize:clear; \
php artisan event:cache; \
php artisan config:cache; \
php artisan route:cache;
php artisan optimize;
}
if [ "$1" != "" ]; then
exec "$@"
elif [ ${container_mode} = "http" ]; then
echo "Octane Server: $octane_server"
elif [ "${container_mode}" = "http" ]; then
initialStuff
if [ ${octane_server} = "frankenphp" ]; then
echo "Octane Server: $octane_server"
if [ "${octane_server}" = "frankenphp" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
elif [ ${octane_server} = "swoole" ]; then
elif [ "${octane_server}" = "swoole" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
elif [ ${octane_server} = "roadrunner" ]; then
elif [ "${octane_server}" = "roadrunner" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
else
echo "Invalid Octane server supplied."
exit 1
fi
elif [ ${container_mode} = "horizon" ]; then
elif [ "${container_mode}" = "horizon" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf
elif [ ${container_mode} = "scheduler" ]; then
elif [ "${container_mode}" = "reverb" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.reverb.conf
elif [ "${container_mode}" = "scheduler" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.scheduler.conf
elif [ ${container_mode} = "worker" ]; then
elif [ "${container_mode}" = "worker" ]; then
if [ -z "${WORKER_COMMAND}" ]; then
echo "WORKER_COMMAND is undefined."
exit 1
fi
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.worker.conf
else

View File

@@ -1,14 +1,13 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[unix_http_server]
file=/var/run/supervisor.sock
nodaemon = true
user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[inet_http_server]
port = 127.0.0.1:9001
[rpcinterface:supervisor]
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

View File

@@ -1,14 +1,14 @@
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=3600
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
stopwaitsecs = 3600
[include]
files=/etc/supervisor/supervisord.conf
files = /etc/supervisord.conf

View File

@@ -0,0 +1,14 @@
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
minfds = 10000
[include]
files = /etc/supervisord.conf

View File

@@ -1,26 +1,26 @@
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=true
autorestart=false
startsecs=0
startretries=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = true
autorestart = false
startsecs = 0
startretries = 1
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
[include]
files=/etc/supervisor/supervisord.conf
files = /etc/supervisord.conf

View File

@@ -1,42 +0,0 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/horizon.log
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/scheduler.log
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
stdout_logfile=%(ENV_ROOT)s/scheduler.log

View File

@@ -1,13 +1,13 @@
[program:worker]
process_name=%(program_name)s_%(process_num)02d
command=%(ENV_WORKER_COMMAND)s
user=%(ENV_USER)s
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
process_name = %(program_name)s_%(process_num)s
command = %(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
[include]
files=/etc/supervisor/supervisord.conf
files = /etc/supervisord.conf

View File

@@ -1,12 +0,0 @@
tinker() {
if [ -z "$1" ]; then
php artisan tinker
else
php artisan tinker --execute="\"dd($1);\""
fi
}
# Commonly used aliases
alias ..="cd .."
alias ...="cd ../.."
alias art="php artisan"

View File

@@ -102,7 +102,7 @@ test('test that updating billable rate works with existing time entries', async
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
await page.getByText('Non-Billable').click();
await page.getByText('Non-Billable').click();
await page.getByText('Custom Rate').click();
await page
.getByPlaceholder('Billable Rate')
@@ -111,8 +111,8 @@ test('test that updating billable rate works with existing time entries', async
await Promise.all([
page
.getByRole('button', { name: 'Yes, update existing time entries' })
.click(),
.locator('button').filter({ hasText: 'Yes, update existing time' })
.click(),
page.waitForRequest(
async (request) =>
request.url().includes('/projects/') &&

View File

@@ -9,6 +9,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
@@ -45,6 +46,7 @@ return [
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -9,7 +9,8 @@ return [
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'In the free Clockify plan it\'s currently not possible to select more than one year. '.
'You can export each year separately and import them one after another.'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
],

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