Compare commits

...

20 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
177 changed files with 3449 additions and 2260 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

@@ -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();
@@ -28,7 +32,7 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->twiceDaily();
->everySixHours();
}
/**

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

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

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

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

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

View File

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

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,6 +6,7 @@ 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,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

@@ -24,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;
@@ -37,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',
@@ -46,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([
@@ -197,7 +207,6 @@ class DatabaseSeeder extends Seeder
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

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

@@ -12,3 +12,7 @@ parameters:
checkOctaneCompatibility: true
checkModelProperties: true
noEnvCallsOutsideOfConfig: true
ignoreErrors:
- '# is not subtype of native type Illuminate\\Database\\Eloquent\\Builder#'
- '# is not subtype of native type Illuminate\\Database\\Eloquent\\Relations\\Relation#'

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { Member } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { useForm } from '@tanstack/vue-form';
import { useMutation } from '@tanstack/vue-query';
import Modal from '@/packages/ui/src/Modal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import { useNotificationsStore } from '@/utils/notification';
import { getCurrentOrganizationId } from '@/utils/useUser';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import InputError from '@/packages/ui/src/Input/InputError.vue';
import { useMembersStore } from '@/utils/useMembers';
const props = defineProps<{
show: boolean;
member: Member;
}>();
const emit = defineEmits<{
'update:show': [value: boolean];
}>();
const { handleApiRequestNotifications } = useNotificationsStore();
const deleteMutation = useMutation({
mutationFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) {
throw new Error('No organization ID found');
}
return api.removeMember(undefined, {
params: {
member: props.member.id,
organization: organizationId,
},
queries: {
delete_related: 'true',
},
});
},
onSuccess: () => {
close();
useMembersStore().fetchMembers();
}
});
const form = useForm({
canSubmitWhenInvalid: true,
defaultValues: {
confirmDelete: false,
},
onSubmit: async () => {
await handleApiRequestNotifications(
() => deleteMutation.mutateAsync(),
'Member deleted successfully',
'Error deleting member'
);
},
});
const close = () => {
emit('update:show', false);
form.reset();
};
</script>
<template>
<Modal :show="show" max-width="md" @close="close">
<div class="p-6">
<h2 class="text-lg font-medium text-text-primary">
Delete Member
</h2>
<div class="mt-4 text-sm text-text-secondary">
<p class="mb-4">
Are you sure you want to delete {{ member.name }}? This action cannot be undone.
</p>
<p class="mb-4">
This will permanently delete:
</p>
<ul class="list-disc ml-6 mt-2">
<li>All time entries created by this member</li>
<li>Their project assignments</li>
<li>Their organization membership</li>
</ul>
<p class="pt-4">
<strong>Note:</strong> Deleting time entries will affect all reports and statistics.
If you want to keep the time entries but remove the member from your organization, you can convert them to a placeholder user instead. Placeholder users are not charged and their time entries remain intact for reporting purposes.
</p>
</div>
<form
class="mt-6" @submit="
(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}
">
<div class="flex items-start">
<form.Field
name="confirmDelete"
:validators="{
onSubmit: ({value}) => {
if (!value) {
return 'You must confirm that you understand the consequences of this action';
}
return '';
}
}"
>
<template #default="{ field }">
<div class="flex flex-col">
<div class="flex items-center space-x-3 text-sm">
<Checkbox
:id="field.name"
:name="field.name"
:checked="field.state.value"
@update:checked="field.handleChange"
@blur="field.handleBlur"
/>
<InputLabel :for="field.name" class="font-medium text-text-primary">
I understand that this will permanently delete all data related to this member
</InputLabel>
</div>
<InputError class="pl-7 pt-2" :message="field.state.meta.errors[0]" />
</div>
</template>
</form.Field>
</div>
<div class="mt-6 flex justify-end space-x-3">
<SecondaryButton @click="close">Cancel</SecondaryButton>
<form.Subscribe>
<template #default="{ canSubmit, isSubmitting }">
<DangerButton
type="submit"
:disabled="!canSubmit"
>
{{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
</DangerButton>
</template>
</form.Subscribe>
</div>
</form>
</div>
</Modal>
</template>

View File

@@ -49,15 +49,6 @@ const props = defineProps<{
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteMembers()"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('delete')">
<TrashIcon class="w-5" />
<span>Delete</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.member.role === 'placeholder' && canMergeMembers()"
:aria-label="'Merge Member ' + props.member.name"
@@ -75,6 +66,15 @@ const props = defineProps<{
<UserCircleIcon class="w-5 text-icon-active" />
<span>Deactivate</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteMembers()"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('delete')">
<TrashIcon class="w-5" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -8,26 +8,30 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
import { useMembersStore } from '@/utils/useMembers';
const props = defineProps<{
member: Member;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const memberStore = useMembersStore();
const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false);
const showMakeMemberPlaceholderModal = ref(false);
const showDeleteMemberModal = ref(false);
function removeMember() {
useMembersStore().removeMember(props.member.id);
showDeleteMemberModal.value = true;
memberStore.fetchMembers();
}
async function invitePlaceholder(id: string) {
@@ -121,6 +125,9 @@ const userHasValidMailAddress = computed(() => {
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
<MemberDeleteModal
v-model:show="showDeleteMemberModal"
:member="member"></MemberDeleteModal>
</TableRow>
</template>

View File

@@ -17,7 +17,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVar } from '@vueuse/core';
import { useCssVariable } from '@/utils/useCssVariable';
use([
CanvasRenderer,
@@ -47,8 +47,10 @@ const xAxisLabels = computed(() => {
formatDate(el.key ?? '', organization?.value?.date_format)
);
});
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const accentColor = useCssVariable('--theme-color-chart');
const labelColor = useCssVariable('--color-text-secondary');
const markLineColor = useCssVariable('--color-border-secondary');
const splitLineColor = useCssVariable('--color-border-tertiary');
const seriesData = computed(() => {
return props?.groupedData?.map((el) => {
@@ -111,7 +113,7 @@ const option = computed(() => ({
data: xAxisLabels.value,
markLine: {
lineStyle: {
color: 'rgba(125,156,188,0.1)',
color: markLineColor.value,
type: 'dashed',
},
},
@@ -135,9 +137,13 @@ const option = computed(() => ({
},
yAxis: {
type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: {
lineStyle: {
color: 'rgba(125,156,188,0.2)', // Set desired color here
color: splitLineColor.value,
},
},
},

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
import { useCssVariable } from '@/utils/useCssVariable';
import type { Organization } from '@/packages/api/src';
use([
@@ -36,7 +36,7 @@ type ReportingChartDataEntry = {
const props = defineProps<{
data: ReportingChartDataEntry | null;
}>();
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const labelColor = useCssVariable('--color-text-secondary');
const seriesData = computed(() => {
return props.data?.map((el) => {

View File

@@ -19,7 +19,7 @@ import {
formatHumanReadableDuration,
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
import { useCssVariable } from '@/utils/useCssVariable';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src';
@@ -64,12 +64,9 @@ const max = computed(() => {
}
});
const backgroundColor = useCssVar('--color-card-background', null, {
observe: true,
});
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, {
observe: true,
});
const backgroundColor = useCssVariable('--theme-color-card-background');
const itemBackgroundColor = useCssVariable('--color-bg-tertiary');
const borderColor = useCssVariable('--color-border');
const option = computed(() => {
return {
@@ -120,7 +117,7 @@ const option = computed(() => {
[],
itemStyle: {
borderRadius: 5,
borderColor: 'rgba(255,255,255,0.05)',
borderColor: borderColor.value,
borderWidth: 1,
},
tooltip: {

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import VChart from 'vue-echarts';
import { computed, ref } from 'vue';
import { useCssVar } from '@vueuse/core';
import { computed } from 'vue';
import { useCssVariable } from '@/utils/useCssVariable';
const props = defineProps<{
history: number[];
}>();
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const accentColor = useCssVariable('--theme-color-chart');
const markLineColor = useCssVariable('--color-border-secondary');
const seriesData = computed(() => props.history.map((el) => {
return {
@@ -22,7 +23,7 @@ const seriesData = computed(() => props.history.map((el) => {
},
};
}));
const option = ref({
const option = computed(() => ({
grid: {
top: 0,
right: 0,
@@ -35,7 +36,7 @@ const option = ref({
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
markLine: {
lineStyle: {
color: 'rgba(125,156,188,0.1)',
color: markLineColor.value,
type: 'dashed',
},
},
@@ -66,11 +67,11 @@ const option = ref({
},
series: [
{
data: seriesData,
data: seriesData.value,
type: 'bar',
},
],
});
}));
</script>
<template>

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core";
import { useCssVariable } from '@/utils/useCssVariable';
import type { Organization } from "@/packages/api/src";
use([
@@ -24,7 +24,7 @@ use([
]);
provide(THEME_KEY, 'dark');
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const labelColor = useCssVariable('--color-text-secondary');
const props = defineProps<{
weeklyProjectOverview: {

View File

@@ -18,7 +18,7 @@ import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVar } from '@vueuse/core';
import { useCssVariable } from '@/utils/useCssVariable';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -60,7 +60,7 @@ const weekdays = computed(() => {
}
});
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const accentColor = useCssVariable('--theme-color-chart');
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
@@ -176,10 +176,8 @@ const seriesData = computed(() => {
});
});
const markLineColor = useCssVar('--color-border-secondary', null, {
observe: true,
});
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const markLineColor = useCssVariable('--color-border-secondary');
const labelColor = useCssVariable('--color-text-secondary');
const option = computed(() => {
return {
tooltip: {
@@ -215,6 +213,10 @@ const option = computed(() => {
},
yAxis: {
type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: {
lineStyle: {
color: markLineColor.value,

View File

@@ -6,8 +6,8 @@ import {
} from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon } from 'lucide-vue-next';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { CalendarIcon, XIcon } from 'lucide-vue-next';
import { formatDate } from '@/packages/ui/src/utils/time';
import { parseDate } from '@internationalized/date';
import { computed, inject, type ComputedRef } from 'vue';
import { type Organization } from '@/packages/api/src';
@@ -17,6 +17,10 @@ const emit = defineEmits<{
blur: [];
}>();
defineProps<{
clearable?: boolean;
}>();
const handleChange = (date: string) => {
model.value = date;
};
@@ -25,6 +29,11 @@ const handleBlur = () => {
emit('blur');
};
const handleClear = (event: Event) => {
event.stopPropagation();
model.value = null;
};
const date = computed(() => {
return model.value ? parseDate(model.value) : undefined;
});
@@ -44,7 +53,17 @@ const organization = inject<ComputedRef<Organization>>('organization');
]"
>
<CalendarIcon class="mr-2 h-4 w-4" />
{{ model ? formatDateLocalized(model, organization?.date_format) : 'Pick a date' }}
<span class="flex-1">
{{ model ? formatDate(model, organization?.date_format) : 'Pick a date' }}
</span>
<button
v-if="clearable && model"
class="ml-2 hover:bg-muted rounded p-1 transition-colors"
type="button"
@click="handleClear"
>
<XIcon class="h-4 w-4" />
</button>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">

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