diff --git a/.env.example b/.env.example index 1886023d..24ed1310 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,13 @@ +# Application APP_NAME=solidtime APP_ENV=local APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_DEBUG=true APP_URL=https://solidtime.test AUDITING_ENABLED=true +APP_ENABLE_REGISTRATION=true SUPER_ADMINS=admin@example.com +PAGINATION_PER_PAGE_DEFAULT=500 # Logging LOG_CHANNEL=single @@ -25,9 +28,16 @@ DB_TEST_DATABASE=laravel DB_TEST_USERNAME=root DB_TEST_PASSWORD=root -BROADCAST_DRIVER=log +# Broadcasting +BROADCAST_DRIVER=null + +# Cache CACHE_DRIVER=file + +# Queue QUEUE_CONNECTION=sync + +# Session SESSION_DRIVER=database SESSION_LIFETIME=120 @@ -41,14 +51,6 @@ MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_NAME="${APP_NAME}" -PUSHER_APP_ID= -PUSHER_APP_KEY= -PUSHER_APP_SECRET= -PUSHER_HOST= -PUSHER_PORT=443 -PUSHER_SCHEME=https -PUSHER_APP_CLUSTER=mt1 - # Filesystems FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3 @@ -65,16 +67,9 @@ GOTENBERG_URL=http://gotenberg:3000 VITE_HOST_NAME=vite.solidtime.test VITE_APP_NAME="${APP_NAME}" -VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" -VITE_PUSHER_HOST="${PUSHER_HOST}" -VITE_PUSHER_PORT="${PUSHER_PORT}" -VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" -VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" +# Local setup NGINX_HOST_NAME=solidtime.test NETWORK_NAME=reverse-proxy-docker-traefik_routing - FORWARD_DB_PORT=5432 FORWARD_WEB_PORT=8083 - -PAGINATION_PER_PAGE_DEFAULT=500 diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 2eeed782..df8fc21d 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,16 +4,14 @@ declare(strict_types=1); namespace App\Actions\Fortify; -use App\Enums\Role; use App\Enums\Weekday; use App\Events\NewsletterRegistered; -use App\Models\Organization; use App\Models\User; use App\Service\IpLookup\IpLookupServiceContract; use App\Service\TimezoneService; +use App\Service\UserService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; @@ -34,6 +32,12 @@ class CreateNewUser implements CreatesNewUsers */ public function create(array $input): User { + if (! config('app.enable_registration')) { + throw ValidationException::withMessages([ + 'email' => [__('Registration is disabled.')], + ]); + } + Validator::make($input, [ 'name' => [ 'required', @@ -81,30 +85,16 @@ class CreateNewUser implements CreatesNewUsers $currency = $ipLookupResponse->currency; } $user = null; - $organization = null; - DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void { - $user = User::create([ - 'name' => $input['name'], - 'email' => $input['email'], - 'password' => Hash::make($input['password']), - 'timezone' => $timezone ?? 'UTC', - 'week_start' => $startOfWeek, - ]); - - $organization = new Organization; - $organization->name = explode(' ', $user->name, 2)[0]."'s Organization"; - $organization->personal_team = true; - $organization->currency = $currency ?? 'EUR'; - $organization->owner()->associate($user); - $organization->save(); - - $organization->users()->attach( - $user, [ - 'role' => Role::Owner->value, - ] + DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void { + $userService = app(UserService::class); + $user = $userService->createUser( + $input['name'], + $input['email'], + $input['password'], + $timezone ?? 'UTC', + $startOfWeek, + $currency ?? 'EUR', ); - - $user->ownedTeams()->save($organization); }); $newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent']; diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 75574c3a..556eb8d1 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -7,18 +7,16 @@ namespace App\Actions\Jetstream; use App\Enums\Role; use App\Models\Organization; use App\Models\User; +use App\Service\MemberService; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\In; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Laravel\Jetstream\Contracts\AddsTeamMembers; -use Laravel\Jetstream\Events\AddingTeamMember; -use Laravel\Jetstream\Events\TeamMemberAdded; class AddOrganizationMember implements AddsTeamMembers { @@ -36,15 +34,7 @@ class AddOrganizationMember implements AddsTeamMembers ->where('is_placeholder', '=', false) ->firstOrFail(); - AddingTeamMember::dispatch($organization, $newOrganizationMember); - - DB::transaction(function () use ($organization, $newOrganizationMember, $role): void { - $organization->users()->attach( - $newOrganizationMember, ['role' => $role] - ); - }); - - TeamMemberAdded::dispatch($organization, $newOrganizationMember); + app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role)); } /** diff --git a/app/Console/Commands/Admin/UserCreateCommand.php b/app/Console/Commands/Admin/UserCreateCommand.php new file mode 100644 index 00000000..08d67d18 --- /dev/null +++ b/app/Console/Commands/Admin/UserCreateCommand.php @@ -0,0 +1,89 @@ +argument('name'); + $email = $this->argument('email'); + $askForPassword = (bool) $this->option('ask-for-password'); + + if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) { + $this->error('User with email "'.$email.'" already exists.'); + + return self::FAILURE; + } + + if ($askForPassword) { + $outputPassword = false; + $password = $this->secret('Enter the password'); + } else { + $outputPassword = true; + $password = bin2hex(random_bytes(16)); + } + + $user = null; + DB::transaction(function () use (&$user, $name, $email, $password): void { + $user = app(UserService::class)->createUser( + $name, + $email, + $password, + 'UTC', + Weekday::Monday, + 'EUR', + ); + }); + /** @var Organization|null $organization */ + $organization = $user->ownedTeams->first(); + if ($organization === null) { + throw new LogicException('User does not have an organization'); + } + + $this->info('Created user "'.$name.'" ("'.$email.'")'); + $this->line('ID: '.$user->getKey()); + $this->line('Name: '.$name); + $this->line('Email: '.$email); + if ($outputPassword) { + $this->line('Password: '.$password); + } + $this->line('Timezone: '.$user->timezone); + $this->line('Week start: '.$user->week_start->value); + + // Organization + $this->line('Currency: '.$organization->currency); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Admin/UserVerifyCommand.php b/app/Console/Commands/Admin/UserVerifyCommand.php index aa588a18..b7f8d51f 100644 --- a/app/Console/Commands/Admin/UserVerifyCommand.php +++ b/app/Console/Commands/Admin/UserVerifyCommand.php @@ -35,7 +35,9 @@ class UserVerifyCommand extends Command $this->info('Start verifying user with email "'.$email.'"'); /** @var User|null $user */ - $user = User::where('email', $email)->first(); + $user = User::query()->where('email', $email) + ->where('is_placeholder', '=', false) + ->first(); if ($user === null) { $this->error('User with email "'.$email.'" not found.'); diff --git a/app/Filament/Resources/ClientResource/Pages/EditClient.php b/app/Filament/Resources/ClientResource/Pages/EditClient.php index 36a59497..028b3204 100644 --- a/app/Filament/Resources/ClientResource/Pages/EditClient.php +++ b/app/Filament/Resources/ClientResource/Pages/EditClient.php @@ -15,7 +15,8 @@ class EditClient extends EditRecord protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + Actions\DeleteAction::make() + ->icon('heroicon-m-trash'), ]; } } diff --git a/app/Filament/Resources/ClientResource/Pages/ListClients.php b/app/Filament/Resources/ClientResource/Pages/ListClients.php index c468a5b7..65a2fbd2 100644 --- a/app/Filament/Resources/ClientResource/Pages/ListClients.php +++ b/app/Filament/Resources/ClientResource/Pages/ListClients.php @@ -15,7 +15,8 @@ class ListClients extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/OrganizationInvitationResource.php b/app/Filament/Resources/OrganizationInvitationResource.php new file mode 100644 index 00000000..88ca23a5 --- /dev/null +++ b/app/Filament/Resources/OrganizationInvitationResource.php @@ -0,0 +1,114 @@ +columns(1) + ->schema([ + Forms\Components\TextInput::make('email') + ->label('Email') + ->disabledOn(['edit']) + ->required(), + Select::make('role') + ->options(Role::class), + Forms\Components\Select::make('organization_id') + ->label('Organization') + ->relationship(name: 'organization', titleAttribute: 'name') + ->searchable(['name']) + ->disabledOn(['edit']) + ->required(), + Forms\Components\DateTimePicker::make('created_at') + ->label('Created At') + ->hiddenOn(['create']) + ->disabled(), + Forms\Components\DateTimePicker::make('updated_at') + ->label('Updated At') + ->hiddenOn(['create']) + ->disabled(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('organization.name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('email') + ->sortable(), + Tables\Columns\TextColumn::make('role'), + Tables\Columns\TextColumn::make('created_at') + ->label('Created At') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('updated_at') + ->label('Updated At') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\BulkAction::make('resend') + ->label('Resend') + ->action(function (Collection $records): void { + foreach ($records as $organizationInvite) { + app(OrganizationInvitationService::class)->resend($organizationInvite); + } + }), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListOrganizationInvitations::route('/'), + 'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'), + 'view' => Pages\ViewOrganizationInvitation::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php b/app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php new file mode 100644 index 00000000..5011d0db --- /dev/null +++ b/app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php @@ -0,0 +1,19 @@ +icon('heroicon-s-pencil'), + ]; + } +} diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index 5561cb53..b0abcc0f 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Filament\Resources; use App\Filament\Resources\OrganizationResource\Pages; +use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Models\Organization; +use App\Service\DeletionService; use App\Service\Export\ExportService; use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImportException; @@ -46,10 +48,13 @@ class OrganizationResource extends Resource ->maxLength(255), Forms\Components\Toggle::make('personal_team') ->label('Is personal?') + ->hiddenOn(['create']) ->required(), Forms\Components\Select::make('user_id') + ->label('Owner') ->relationship(name: 'owner', titleAttribute: 'email') ->searchable(['name', 'email']) + ->disabledOn(['edit']) ->required(), Forms\Components\Select::make('currency') ->label('Currency') @@ -62,6 +67,7 @@ class OrganizationResource extends Resource return $select; }) + ->required() ->searchable(), Forms\Components\TextInput::make('billable_rate') ->label('Billable rate (in Cents)') @@ -75,9 +81,11 @@ class OrganizationResource extends Resource ->numeric(), Forms\Components\DateTimePicker::make('created_at') ->label('Created At') + ->hiddenOn(['create']) ->disabled(), Forms\Components\DateTimePicker::make('updated_at') ->label('Updated At') + ->hiddenOn(['create']) ->disabled(), ]); } @@ -97,7 +105,7 @@ class OrganizationResource extends Resource ->sortable(), Tables\Columns\TextColumn::make('currency'), TextColumn::make('billable_rate') - ->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100), + ->money(fn (Organization $resource) => $resource->currency, divideBy: 100), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), @@ -112,6 +120,10 @@ class OrganizationResource extends Resource ]) ->actions([ Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make() + ->using(function (Organization $record): void { + app(DeletionService::class)->deleteOrganization($record); + }), Action::make('Export') ->icon('heroicon-o-arrow-down-tray') ->action(function (Organization $record) { @@ -199,8 +211,6 @@ class OrganizationResource extends Resource ]), ]) ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - ]), ]); } @@ -208,6 +218,7 @@ class OrganizationResource extends Resource { return [ UsersRelationManager::class, + InvitationsRelationManager::class, ]; } diff --git a/app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php b/app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php index ed138e96..eb53e58c 100644 --- a/app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php +++ b/app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php @@ -15,7 +15,6 @@ class DeleteOrganization extends DeleteAction protected function setUp(): void { parent::setUp(); - // TODO: check why setting the icon is necessary $this->icon('heroicon-m-trash'); $this->action(function (): void { $result = $this->process(function (Organization $record): bool { diff --git a/app/Filament/Resources/OrganizationResource/Pages/CreateOrganization.php b/app/Filament/Resources/OrganizationResource/Pages/CreateOrganization.php index 5466056d..6d350394 100644 --- a/app/Filament/Resources/OrganizationResource/Pages/CreateOrganization.php +++ b/app/Filament/Resources/OrganizationResource/Pages/CreateOrganization.php @@ -4,10 +4,33 @@ declare(strict_types=1); namespace App\Filament\Resources\OrganizationResource\Pages; +use App\Enums\Role; use App\Filament\Resources\OrganizationResource; +use App\Models\Organization; use Filament\Resources\Pages\CreateRecord; class CreateOrganization extends CreateRecord { protected static string $resource = OrganizationResource::class; + + protected function mutateFormDataBeforeCreate(array $data): array + { + $data['personal_team'] = false; + + return $data; + } + + protected function afterCreate(): void + { + /** @var Organization $organization */ + $organization = $this->record; + + $user = $organization->owner; + + $organization->users()->attach( + $user, [ + 'role' => Role::Owner->value, + ] + ); + } } diff --git a/app/Filament/Resources/OrganizationResource/Pages/ListOrganizations.php b/app/Filament/Resources/OrganizationResource/Pages/ListOrganizations.php index 0db975bb..93cb0cdc 100644 --- a/app/Filament/Resources/OrganizationResource/Pages/ListOrganizations.php +++ b/app/Filament/Resources/OrganizationResource/Pages/ListOrganizations.php @@ -15,7 +15,8 @@ class ListOrganizations extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php b/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php new file mode 100644 index 00000000..be5e7e5d --- /dev/null +++ b/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php @@ -0,0 +1,86 @@ +schema([ + TextInput::make('email') + ->label('Email') + ->disabledOn(['edit']) + ->required(), + Select::make('role') + ->options(Role::class) + ->label('Role') + ->rules([ + 'required', + 'string', + Rule::enum(Role::class) + ->except([Role::Owner, Role::Placeholder]), + ]) + ->required(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('email') + ->modelLabel('Invitation') + ->pluralModelLabel('Invitations') + ->columns([ + Tables\Columns\TextColumn::make('email'), + Tables\Columns\TextColumn::make('role'), + ]) + ->headerActions([ + Tables\Actions\CreateAction::make() + ->icon('heroicon-s-plus') + ->using(function (array $data, string $model): Model { + /** @var Organization $ownerRecord */ + $ownerRecord = $this->getOwnerRecord(); + + return app(InvitationService::class) + ->inviteUser($ownerRecord, $data['email'], Role::from($data['role'])); + }), + ]) + ->actions([ + Action::make('view') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [ + 'record' => $record->getKey(), + ])), + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DetachBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php b/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php index e8e9dcdf..d09b1057 100644 --- a/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php +++ b/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php @@ -5,17 +5,24 @@ declare(strict_types=1); namespace App\Filament\Resources\OrganizationResource\RelationManagers; use App\Enums\Role; +use App\Exceptions\Api\ApiException; use App\Filament\Resources\UserResource; +use App\Models\Member; +use App\Models\Organization; use App\Models\User; +use App\Service\BillableRateService; +use App\Service\MemberService; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Actions\Action; use Filament\Tables\Actions\AttachAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Validation\Rule; class UsersRelationManager extends RelationManager { @@ -36,20 +43,39 @@ class UsersRelationManager extends RelationManager public function table(Table $table): Table { + /** @var Organization $organization */ + $organization = $this->getOwnerRecord(); + return $table ->recordTitleAttribute('name') ->columns([ Tables\Columns\TextColumn::make('name'), Tables\Columns\TextColumn::make('role'), TextColumn::make('billable_rate') - ->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100), + ->money($organization->currency, divideBy: 100), ]) ->headerActions([ - Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [ - $action->getRecordSelect(), - Select::make('role') - ->options(Role::class), - ]), + Tables\Actions\AttachAction::make() + ->form(fn (AttachAction $action): array => [ + $action->getRecordSelect(), + Select::make('role') + ->required() + ->options(Role::class) + ->rule([ + 'required', + 'string', + Rule::enum(Role::class) + ->except([Role::Owner, Role::Placeholder]), + ]), + ]) + ->label('Add user') + ->modalHeading('Add user') + ->icon('heroicon-s-plus') + ->using(function (User $record, array $data): void { + /** @var Organization $organization */ + $organization = $this->getOwnerRecord(); + app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true); + }), ]) ->actions([ Action::make('view') @@ -58,13 +84,55 @@ class UsersRelationManager extends RelationManager ->url(fn (User $record): string => UserResource::getUrl('view', [ 'record' => $record->getKey(), ])), - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\EditAction::make() + ->using(function (User $record, array $data): User { + /** @var Organization $organization */ + $organization = $this->getOwnerRecord(); + /** @var Member $member */ + $member = $record->getRelation('membership'); + + if ($data['billable_rate'] !== $member->billable_rate) { + $member->billable_rate = $data['billable_rate']; + app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member); + } + + if ($data['role'] !== $member->role) { + try { + app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true); + } catch (ApiException $exception) { + Notification::make() + ->danger() + ->title('Update failed') + ->body($exception->getTranslatedMessage()) + ->persistent() + ->send(); + } + } + $member->save(); + + return $record; + }), + Tables\Actions\DetachAction::make() + ->using(function (User $record): void { + /** @var Organization $organization */ + $organization = $this->getOwnerRecord(); + $member = Member::query() + ->whereBelongsTo($record, 'user') + ->whereBelongsTo($organization, 'organization') + ->firstOrFail(); + try { + app(MemberService::class)->removeMember($member, $organization); + } catch (ApiException $exception) { + Notification::make() + ->danger() + ->title('Delete failed') + ->body($exception->getTranslatedMessage()) + ->persistent() + ->send(); + } + }), ]) ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), - ]), ]); } } diff --git a/app/Filament/Resources/ProjectMemberResource/Pages/EditProjectMember.php b/app/Filament/Resources/ProjectMemberResource/Pages/EditProjectMember.php index 6563f027..5852ae81 100644 --- a/app/Filament/Resources/ProjectMemberResource/Pages/EditProjectMember.php +++ b/app/Filament/Resources/ProjectMemberResource/Pages/EditProjectMember.php @@ -15,7 +15,8 @@ class EditProjectMember extends EditRecord protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + Actions\DeleteAction::make() + ->icon('heroicon-m-trash'), ]; } } diff --git a/app/Filament/Resources/ProjectMemberResource/Pages/ListProjectMembers.php b/app/Filament/Resources/ProjectMemberResource/Pages/ListProjectMembers.php index 81c07d2a..a91bb454 100644 --- a/app/Filament/Resources/ProjectMemberResource/Pages/ListProjectMembers.php +++ b/app/Filament/Resources/ProjectMemberResource/Pages/ListProjectMembers.php @@ -15,7 +15,8 @@ class ListProjectMembers extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/ProjectResource/Pages/EditProject.php b/app/Filament/Resources/ProjectResource/Pages/EditProject.php index 1df03ff8..13eae2e6 100644 --- a/app/Filament/Resources/ProjectResource/Pages/EditProject.php +++ b/app/Filament/Resources/ProjectResource/Pages/EditProject.php @@ -15,7 +15,8 @@ class EditProject extends EditRecord protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + Actions\DeleteAction::make() + ->icon('heroicon-m-trash'), ]; } } diff --git a/app/Filament/Resources/ProjectResource/Pages/ListProjects.php b/app/Filament/Resources/ProjectResource/Pages/ListProjects.php index b2d025ce..b44bbc0a 100644 --- a/app/Filament/Resources/ProjectResource/Pages/ListProjects.php +++ b/app/Filament/Resources/ProjectResource/Pages/ListProjects.php @@ -15,7 +15,8 @@ class ListProjects extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/ReportResource.php b/app/Filament/Resources/ReportResource.php new file mode 100644 index 00000000..064bdcfa --- /dev/null +++ b/app/Filament/Resources/ReportResource.php @@ -0,0 +1,136 @@ +columns(1) + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('description') + ->label('Description') + ->nullable() + ->maxLength(255), + Toggle::make('is_public') + ->label('Is public?') + ->required(), + DateTimePicker::make('public_until') + ->label('Public until') + ->nullable(), + Forms\Components\Select::make('organization_id') + ->label('Organization') + ->relationship(name: 'organization', titleAttribute: 'name') + ->searchable(['name']) + ->disabled() + ->required(), + Forms\Components\TextInput::make('share_secret') + ->label('Share Secret') + ->nullable(), + PrettyJson::make('properties') + ->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string { + return $record->getRawOriginal('properties'); + }) + ->disabled(), + Forms\Components\DateTimePicker::make('created_at') + ->label('Created At') + ->hiddenOn(['create']) + ->disabled(), + Forms\Components\DateTimePicker::make('updated_at') + ->label('Updated At') + ->hiddenOn(['create']) + ->disabled(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('description') + ->searchable() + ->sortable(), + ToggleColumn::make('is_public') + ->label('Is public?') + ->sortable(), + TextColumn::make('organization.name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('organization') + ->relationship('organization', 'name') + ->searchable(), + ]) + ->actions([ + Action::make('public-view') + ->label('Public') + ->icon('heroicon-o-eye') + ->color('gray') + ->hidden(fn (Report $record): bool => $record->getShareableLink() === null) + ->url(fn (Report $record): string => $record->getShareableLink(), true), + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + ]); + } + + public static function getRelations(): array + { + return [ + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListReports::route('/'), + 'edit' => Pages\EditReport::route('/{record}/edit'), + 'view' => Pages\ViewReport::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/ReportResource/Pages/EditReport.php b/app/Filament/Resources/ReportResource/Pages/EditReport.php new file mode 100644 index 00000000..1acc1738 --- /dev/null +++ b/app/Filament/Resources/ReportResource/Pages/EditReport.php @@ -0,0 +1,22 @@ +icon('heroicon-m-trash'), + ]; + } +} diff --git a/app/Filament/Resources/ReportResource/Pages/ListReports.php b/app/Filament/Resources/ReportResource/Pages/ListReports.php new file mode 100644 index 00000000..04c3e600 --- /dev/null +++ b/app/Filament/Resources/ReportResource/Pages/ListReports.php @@ -0,0 +1,19 @@ +icon('heroicon-s-pencil'), + ]; + } +} diff --git a/app/Filament/Resources/TagResource/Pages/EditTag.php b/app/Filament/Resources/TagResource/Pages/EditTag.php index 570c84bc..b61b1269 100644 --- a/app/Filament/Resources/TagResource/Pages/EditTag.php +++ b/app/Filament/Resources/TagResource/Pages/EditTag.php @@ -15,7 +15,8 @@ class EditTag extends EditRecord protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + Actions\DeleteAction::make() + ->icon('heroicon-m-trash'), ]; } } diff --git a/app/Filament/Resources/TagResource/Pages/ListTags.php b/app/Filament/Resources/TagResource/Pages/ListTags.php index 5fb0949f..c17bc294 100644 --- a/app/Filament/Resources/TagResource/Pages/ListTags.php +++ b/app/Filament/Resources/TagResource/Pages/ListTags.php @@ -15,7 +15,8 @@ class ListTags extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/TaskResource/Pages/EditTask.php b/app/Filament/Resources/TaskResource/Pages/EditTask.php index 25795be4..26331b2c 100644 --- a/app/Filament/Resources/TaskResource/Pages/EditTask.php +++ b/app/Filament/Resources/TaskResource/Pages/EditTask.php @@ -15,7 +15,8 @@ class EditTask extends EditRecord protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + Actions\DeleteAction::make() + ->icon('heroicon-m-trash'), ]; } } diff --git a/app/Filament/Resources/TaskResource/Pages/ListTasks.php b/app/Filament/Resources/TaskResource/Pages/ListTasks.php index a221527c..4c0519bb 100644 --- a/app/Filament/Resources/TaskResource/Pages/ListTasks.php +++ b/app/Filament/Resources/TaskResource/Pages/ListTasks.php @@ -15,7 +15,8 @@ class ListTasks extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php b/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php index 435d7372..96105ecd 100644 --- a/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php +++ b/app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php @@ -15,7 +15,8 @@ class EditTimeEntry extends EditRecord protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + Actions\DeleteAction::make() + ->icon('heroicon-m-trash'), ]; } } diff --git a/app/Filament/Resources/TimeEntryResource/Pages/ListTimeEntries.php b/app/Filament/Resources/TimeEntryResource/Pages/ListTimeEntries.php index 5d8cf8c0..93f1e497 100644 --- a/app/Filament/Resources/TimeEntryResource/Pages/ListTimeEntries.php +++ b/app/Filament/Resources/TimeEntryResource/Pages/ListTimeEntries.php @@ -15,7 +15,8 @@ class ListTimeEntries extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 149d4933..535a846a 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -5,20 +5,25 @@ declare(strict_types=1); namespace App\Filament\Resources; use App\Enums\Weekday; +use App\Exceptions\Api\ApiException; use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager; use App\Models\User; +use App\Service\DeletionService; use App\Service\TimezoneService; +use Brick\Money\ISOCurrencyProvider; use Exception; use Filament\Forms; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; +use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use STS\FilamentImpersonate\Tables\Actions\Impersonate; @@ -52,7 +57,9 @@ class UserResource extends Resource ->required() ->maxLength(255), Forms\Components\Toggle::make('is_placeholder') - ->label('Is Placeholder'), + ->label('Is Placeholder?') + ->hiddenOn(['create']) + ->disabledOn(['edit']), Forms\Components\DateTimePicker::make('email_verified_at') ->label('Email Verified At') ->nullable(), @@ -71,11 +78,27 @@ class UserResource extends Resource ->dehydrated(fn ($state) => filled($state)) ->required(fn (string $context): bool => $context === 'create') ->maxLength(255), + Forms\Components\Select::make('currency') + ->label('Currency (Personal Organization)') + ->options(function (): array { + $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); + $select = []; + foreach ($currencies as $currency) { + $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; + } + + return $select; + }) + ->required() + ->visibleOn(['create']) + ->searchable(), Forms\Components\DateTimePicker::make('created_at') ->label('Created At') + ->hiddenOn(['create']) ->disabled(), Forms\Components\DateTimePicker::make('updated_at') ->label('Updated At') + ->hiddenOn(['create']) ->disabled(), ]); } @@ -145,11 +168,22 @@ class UserResource extends Resource } }), Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make() + ->hidden(fn (User $record) => $record->is(Auth::user())) + ->using(function (User $record): void { + try { + app(DeletionService::class)->deleteUser($record); + } catch (ApiException $exception) { + Notification::make() + ->danger() + ->title('Delete failed') + ->body($exception->getTranslatedMessage()) + ->persistent() + ->send(); + } + }), ]) ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), - ]), ]); } diff --git a/app/Filament/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Resources/UserResource/Pages/CreateUser.php index b5cac367..3ec53fb2 100644 --- a/app/Filament/Resources/UserResource/Pages/CreateUser.php +++ b/app/Filament/Resources/UserResource/Pages/CreateUser.php @@ -4,24 +4,28 @@ declare(strict_types=1); namespace App\Filament\Resources\UserResource\Pages; +use App\Enums\Weekday; use App\Filament\Resources\UserResource; -use App\Models\Organization; use App\Models\User; +use App\Service\UserService; use Filament\Resources\Pages\CreateRecord; class CreateUser extends CreateRecord { protected static string $resource = UserResource::class; - protected function afterCreate(): void + protected function handleRecordCreation(array $data): User { - /** @var User $user */ - $user = $this->record; + $userService = app(UserService::class); + $user = $userService->createUser( + $data['name'], + $data['email'], + $data['password'], + $data['timezone'], + Weekday::from($data['week_start']), + $data['currency'], + ); - $user->ownedTeams()->save(Organization::forceCreate([ - 'user_id' => $user->id, - 'name' => explode(' ', $user->name, 2)[0]."'s Organization", - 'personal_team' => true, - ])); + return $user; } } diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php index 3298c135..8d04ff0e 100644 --- a/app/Filament/Resources/UserResource/Pages/ListUsers.php +++ b/app/Filament/Resources/UserResource/Pages/ListUsers.php @@ -15,7 +15,8 @@ class ListUsers extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->icon('heroicon-s-plus'), ]; } } diff --git a/app/Filament/Resources/UserResource/Pages/ViewUser.php b/app/Filament/Resources/UserResource/Pages/ViewUser.php index e2def8ee..6b49282d 100644 --- a/app/Filament/Resources/UserResource/Pages/ViewUser.php +++ b/app/Filament/Resources/UserResource/Pages/ViewUser.php @@ -7,6 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; use Filament\Actions\EditAction; use Filament\Resources\Pages\ViewRecord; +use STS\FilamentImpersonate\Pages\Actions\Impersonate; class ViewUser extends ViewRecord { @@ -15,6 +16,7 @@ class ViewUser extends ViewRecord protected function getHeaderActions(): array { return [ + Impersonate::make()->record($this->getRecord()), EditAction::make('edit') ->icon('heroicon-s-pencil'), ]; diff --git a/app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php index fc821a1c..72b31442 100644 --- a/app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php +++ b/app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php @@ -5,15 +5,18 @@ declare(strict_types=1); namespace App\Filament\Resources\UserResource\RelationManagers; use App\Enums\Role; +use App\Exceptions\Api\ApiException; use App\Filament\Resources\OrganizationResource; +use App\Models\Member; use App\Models\Organization; +use App\Models\User; +use App\Service\MemberService; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Forms\Form; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Actions\Action; -use Filament\Tables\Actions\AttachAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -27,10 +30,6 @@ class OrganizationsRelationManager extends RelationManager ->schema([ Select::make('role') ->options(Role::class), - TextInput::make('billable_rate') - ->label('Billable rate (in Cents)') - ->nullable() - ->numeric(), ]); } @@ -41,15 +40,11 @@ class OrganizationsRelationManager extends RelationManager ->columns([ TextColumn::make('name'), TextColumn::make('role'), - TextColumn::make('billable_rate') - ->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100), + TextColumn::make('membership.billable_rate') + ->label('Billable rate') + ->money(fn (Organization $resource) => $resource->currency, divideBy: 100), ]) ->headerActions([ - Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [ - $action->getRecordSelect(), - Select::make('role') - ->options(Role::class), - ]), ]) ->actions([ Action::make('view') @@ -58,13 +53,48 @@ class OrganizationsRelationManager extends RelationManager ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [ 'record' => $record->getKey(), ])), - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\EditAction::make() + ->using(function (Organization $record, array $data): Organization { + /** @var Member $member */ + $member = $record->getRelation('membership'); + + if ($data['role'] !== $member->role) { + try { + app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true); + } catch (ApiException $exception) { + Notification::make() + ->danger() + ->title('Update failed') + ->body($exception->getTranslatedMessage()) + ->persistent() + ->send(); + } + } + $member->save(); + + return $record; + }), + Tables\Actions\DetachAction::make() + ->using(function (Organization $record): void { + /** @var User $user */ + $user = $this->getOwnerRecord(); + $member = Member::query() + ->whereBelongsTo($user, 'user') + ->whereBelongsTo($record, 'organization') + ->firstOrFail(); + try { + app(MemberService::class)->removeMember($member, $record); + } catch (ApiException $exception) { + Notification::make() + ->danger() + ->title('Delete failed') + ->body($exception->getTranslatedMessage()) + ->persistent() + ->send(); + } + }), ]) ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), - ]), ]); } } diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index e2353ec1..4d8a3a33 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -9,13 +9,12 @@ use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Resources\V1\Invitation\InvitationCollection; use App\Http\Resources\V1\Invitation\InvitationResource; -use App\Mail\OrganizationInvitationMail; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Service\InvitationService; +use App\Service\OrganizationInvitationService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Mail; class InvitationController extends Controller { @@ -73,12 +72,11 @@ class InvitationController extends Controller * * @operationId resendInvitationEmail */ - public function resend(Organization $organization, OrganizationInvitation $invitation): JsonResponse + public function resend(Organization $organization, OrganizationInvitation $invitation, OrganizationInvitationService $organizationInvitationService): JsonResponse { $this->checkPermission($organization, 'invitations:resend', $invitation); - Mail::to($invitation->email) - ->queue(new OrganizationInvitationMail($invitation)); + $organizationInvitationService->resend($invitation); return response()->json(null, 204); } diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index bf900a7a..c1e1220f 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1; use App\Enums\Role; use App\Events\MemberMadeToPlaceholder; -use App\Events\MemberRemoved; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\EntityStillInUseApiException; @@ -19,8 +18,6 @@ use App\Http\Resources\V1\Member\MemberCollection; use App\Http\Resources\V1\Member\MemberResource; use App\Models\Member; use App\Models\Organization; -use App\Models\ProjectMember; -use App\Models\TimeEntry; use App\Service\BillableRateService; use App\Service\InvitationService; use App\Service\MemberService; @@ -80,22 +77,8 @@ class MemberController extends Controller } if ($request->has('role') && $member->role !== $request->getRole()->value) { $newRole = $request->getRole(); - $oldRole = Role::from($member->role); - if ($oldRole === Role::Owner) { - throw new OrganizationNeedsAtLeastOneOwner; - } - if ($newRole === Role::Placeholder) { - throw new ChangingRoleToPlaceholderIsNotAllowed; - } - if ($newRole === Role::Owner) { - if ($this->hasPermission($organization, 'members:change-ownership')) { - $memberService->changeOwnership($organization, $member); - } else { - throw new OnlyOwnerCanChangeOwnership; - } - } else { - $member->role = $request->getRole()->value; - } + $allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership'); + $memberService->changeRole($member, $organization, $newRole, $allowOwnerChange); } $member->save(); @@ -109,22 +92,11 @@ class MemberController extends Controller * * @operationId removeMember */ - public function destroy(Organization $organization, Member $member): JsonResponse + public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse { $this->checkPermission($organization, 'members:delete', $member); - 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; - } - - $member->delete(); - MemberRemoved::dispatch($member, $organization); + $memberService->removeMember($member, $organization); return response() ->json(null, 204); diff --git a/app/Service/MemberService.php b/app/Service/MemberService.php index 01f5f4ec..b5c1f6e0 100644 --- a/app/Service/MemberService.php +++ b/app/Service/MemberService.php @@ -5,10 +5,21 @@ declare(strict_types=1); namespace App\Service; use App\Enums\Role; +use App\Events\MemberRemoved; +use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; +use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; +use App\Exceptions\Api\EntityStillInUseApiException; +use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; +use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Models\Member; use App\Models\Organization; +use App\Models\ProjectMember; +use App\Models\TimeEntry; use App\Models\User; +use Illuminate\Support\Facades\DB; use InvalidArgumentException; +use Laravel\Jetstream\Events\AddingTeamMember; +use Laravel\Jetstream\Events\TeamMemberAdded; class MemberService { @@ -19,6 +30,72 @@ class MemberService $this->userService = $userService; } + public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member + { + if (! $asSuperAdmin) { + AddingTeamMember::dispatch($organization, $user); + } + + $member = new Member; + DB::transaction(function () use ($organization, $user, $role, &$member): void { + $member->user()->associate($user); + $member->organization()->associate($organization); + $member->role = $role->value; + $member->save(); + }); + + if (! $asSuperAdmin) { + TeamMemberAdded::dispatch($organization, $user); + } + + return $member; + } + + /** + * @throws CanNotRemoveOwnerFromOrganization + * @throws EntityStillInUseApiException + */ + public function removeMember(Member $member, Organization $organization): 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; + } + + $member->delete(); + MemberRemoved::dispatch($member, $organization); + } + + /** + * @throws ChangingRoleToPlaceholderIsNotAllowed + * @throws OnlyOwnerCanChangeOwnership + * @throws OrganizationNeedsAtLeastOneOwner + */ + public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void + { + $oldRole = Role::from($member->role); + if ($oldRole === Role::Owner) { + throw new OrganizationNeedsAtLeastOneOwner; + } + if ($newRole === Role::Placeholder) { + throw new ChangingRoleToPlaceholderIsNotAllowed; + } + if ($newRole === Role::Owner) { + if ($allowOwnerChange) { + $this->changeOwnership($organization, $member); + } else { + throw new OnlyOwnerCanChangeOwnership; + } + } else { + $member->role = $newRole->value; + } + } + /** * Change the ownership of an organization to a new user. * The previous owner will be demoted to an admin. diff --git a/app/Service/OrganizationInvitationService.php b/app/Service/OrganizationInvitationService.php new file mode 100644 index 00000000..815974ca --- /dev/null +++ b/app/Service/OrganizationInvitationService.php @@ -0,0 +1,18 @@ +email) + ->queue(new OrganizationInvitationMail($invitation)); + } +} diff --git a/app/Service/UserService.php b/app/Service/UserService.php index ff0f8821..c130eded 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -5,15 +5,45 @@ declare(strict_types=1); namespace App\Service; use App\Enums\Role; +use App\Enums\Weekday; use App\Events\AfterCreateOrganization; use App\Models\Member; use App\Models\Organization; use App\Models\ProjectMember; use App\Models\TimeEntry; use App\Models\User; +use Illuminate\Support\Facades\Hash; class UserService { + public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency): User + { + $user = new User; + $user->name = $name; + $user->email = $email; + $user->password = Hash::make($password); + $user->timezone = $timezone; + $user->week_start = $weekStart; + $user->save(); + + $organization = new Organization; + $organization->name = explode(' ', $user->name, 2)[0]."'s Organization"; + $organization->personal_team = true; + $organization->currency = $currency; + $organization->owner()->associate($user); + $organization->save(); + + $organization->users()->attach( + $user, [ + 'role' => Role::Owner->value, + ] + ); + + $user->ownedTeams()->save($organization); + + return $user; + } + /** * Assign all organization entities (time entries, project members) from one user to another. * This is useful when a placeholder user is replaced with a real user. diff --git a/config/app.php b/config/app.php index f2eababf..35a13f39 100644 --- a/config/app.php +++ b/config/app.php @@ -67,6 +67,8 @@ return [ 'force_https' => (bool) env('APP_FORCE_HTTPS', false), + 'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c286c6fb..78c6eb36 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\Project; use App\Models\ProjectMember; +use App\Models\Report; use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; @@ -134,6 +135,7 @@ class DatabaseSeeder extends Seeder 'personal_team' => true, 'currency' => 'USD', ]); + Member::factory()->forUser($rivalOwner)->forOrganization($organizationRival)->role(Role::Owner)->create(); $userRivalManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Other User', 'email' => 'test@rival-company.test', @@ -186,6 +188,7 @@ class DatabaseSeeder extends Seeder // Application tables DB::table((new Audit)->getTable())->delete(); + DB::table((new Report)->getTable())->delete(); DB::table((new TimeEntry)->getTable())->delete(); DB::table((new Task)->getTable())->delete(); DB::table((new Tag)->getTable())->delete(); diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index be1d7f9b..66e027ee 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -13,6 +13,7 @@ use App\Providers\RouteServiceProvider; use App\Service\IpLookup\IpLookupResponseDto; use App\Service\IpLookup\IpLookupServiceContract; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; use Laravel\Fortify\Features; use Laravel\Jetstream\Jetstream; @@ -63,6 +64,31 @@ class RegistrationTest extends TestCase Event::assertNotDispatched(NewsletterRegistered::class); } + public function test_user_registration_fails_if_registration_is_deactivated(): void + { + // Arrange + Event::fake([ + NewsletterRegistered::class, + ]); + Config::set('app.enable_registration', false); + + // Act + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + // Assert + $response->assertInvalid([ + 'email' => 'Registration is disabled.', + ]); + $this->assertFalse(User::query()->where('email', 'test@example.com')->exists()); + Event::assertNotDispatched(NewsletterRegistered::class); + } + public function test_new_user_can_not_register_with_likely_invalid_domain(): void { // Act