Added user and organization deletion system; Added coverage annotations

This commit is contained in:
Constantin Graf
2024-06-07 19:17:40 +02:00
committed by Constantin Graf
parent 8857befc6c
commit 86f5ea47bb
65 changed files with 2651 additions and 135 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Service\DeletionService;
use Laravel\Jetstream\Contracts\DeletesTeams;
class DeleteOrganization implements DeletesTeams
@@ -12,8 +13,8 @@ class DeleteOrganization implements DeletesTeams
/**
* Delete the given team.
*/
public function delete(Organization $team): void
public function delete(Organization $organization): void
{
$team->purge();
app(DeletionService::class)->deleteOrganization($organization);
}
}

View File

@@ -4,51 +4,25 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Laravel\Jetstream\Contracts\DeletesTeams;
use App\Service\DeletionService;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* The team deleter implementation.
*
* @var \Laravel\Jetstream\Contracts\DeletesTeams
*/
protected $deletesTeams;
/**
* Create a new action instance.
*/
public function __construct(DeletesTeams $deletesTeams)
{
$this->deletesTeams = $deletesTeams;
}
/**
* Delete the given user.
*/
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$this->deleteTeams($user);
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
});
}
/**
* Delete the teams and team associations attached to the user.
*/
protected function deleteTeams(User $user): void
{
$user->teams()->detach();
$user->ownedTeams->each(function (Organization $team) {
$this->deletesTeams->delete($team);
});
try {
app(DeletionService::class)->deleteUser($user);
} catch (ApiException $exception) {
throw ValidationException::withMessages([
'password' => $exception->getTranslatedMessage(),
]);
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
class ValidateOrganizationDeletion
{
/**
* Validate that the team can be deleted by the given user.
*
* @param User $user Authenticated user
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*/
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException();
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Models\Organization;
use App\Service\DeletionService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class DeleteOrganizationCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:delete-organization
{ organization : The ID of the organization to delete }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete a organization.';
/**
* Execute the console command.
*/
public function handle(DeletionService $deletionService): int
{
$organizationId = $this->argument('organization');
if (! Str::isUuid($organizationId)) {
$this->error('Organization ID must be a valid UUID.');
return self::FAILURE;
}
/** @var Organization|null $organization */
$organization = Organization::find($organizationId);
if ($organization === null) {
$this->error('Organization with ID '.$organizationId.' not found.');
return self::FAILURE;
}
$this->info('Deleting organization with ID '.$organization->getKey());
$deletionService->deleteOrganization($organization);
$this->info('Organization with ID '.$organization->getKey().' has been deleted.');
return self::SUCCESS;
}
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use phpseclib3\Crypt\RSA;
class SelfHostGenerateKeys extends Command
class SelfHostGenerateKeysCommand extends Command
{
/**
* The name and signature of the console command.

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class BeforeOrganizationDeletion
{
use Dispatchable;
public Organization $organization;
public function __construct(Organization $organization)
{
$this->organization = $organization;
}
}

View File

@@ -13,6 +13,11 @@ abstract class ApiException extends Exception
{
public const string KEY = 'api_exception';
public function __construct()
{
parent::__construct(static::KEY);
}
/**
* Render the exception into an HTTP response.
*/

View File

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

View File

@@ -12,7 +12,7 @@ class EntityStillInUseApiException extends ApiException
public function __construct(string $modelToDelete, string $modelInUse)
{
parent::__construct('', 0, null);
parent::__construct();
$this->modelToDelete = $modelToDelete;
$this->modelInUse = $modelInUse;
}

View File

@@ -169,7 +169,6 @@ class OrganizationResource extends Resource
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\Organization;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
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 {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteOrganization($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
@@ -15,7 +14,7 @@ class EditOrganization extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
OrganizationResource\Actions\DeleteOrganization::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewOrganization extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
class DeleteUser extends DeleteAction
{
protected function setUp(): void
{
parent::setUp();
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (User $record): bool {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteUser($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
@@ -17,7 +16,7 @@ class EditUser extends EditRecord
{
return [
Impersonate::make()->record($this->getRecord()),
Actions\DeleteAction::make(),
UserResource\Actions\DeleteUser::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewUser extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -4,13 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends \App\Http\Controllers\Controller
{
@@ -48,34 +44,4 @@ class Controller extends \App\Http\Controllers\Controller
{
return $this->permissionStore->has($organization, $permission);
}
/**
* @throws AuthorizationException
*/
protected function user(): User
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
$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');
throw new AuthorizationException();
}
return $member;
}
}

View File

@@ -4,11 +4,63 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
use AuthorizesRequests;
use ValidatesRequests;
/**
* @throws AuthorizationException
*/
protected function user(): User
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
/** @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');
throw new AuthorizationException();
}
return $member;
}
/**
* @throws AuthorizationException
*/
protected function currentOrganization(): Organization
{
$user = $this->user();
$organization = $user->currentTeam;
if ($organization === null) {
$organization = $user->organizations()->first();
}
return $organization;
}
}

View File

@@ -4,21 +4,21 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\Organization;
use App\Models\User;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
/**
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
{
/** @var User $user */
$user = auth()->user();
/** @var Organization $organization */
$organization = $user->currentTeam;
$user = $this->user();
$organization = $this->currentOrganization();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);

View File

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
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\Foundation\Auth\User as Authenticatable;
@@ -36,11 +37,12 @@ use Laravel\Passport\HasApiTokens;
* @property bool $is_placeholder
* @property Weekday $week_start
* @property string|null $profile_photo_path
* @property-read Organization $currentTeam
* @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $current_team_id
* @property string|null $current_team_id
* @property Collection<int, Organization> $organizations
* @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership
@@ -154,6 +156,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
return $this->hasMany(TimeEntry::class);
}
/**
* @return BelongsTo<Organization, User>
*/
public function currentOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'current_team_id');
}
/**
* @return HasMany<ProjectMember>
*/

View File

@@ -12,6 +12,7 @@ use App\Actions\Jetstream\InviteOrganizationMember;
use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member;
@@ -26,6 +27,7 @@ use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
@@ -56,6 +58,7 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::useMembershipModel(Member::class);
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'terms_url' => config('auth.terms_url'),
@@ -105,6 +108,7 @@ class JetstreamServiceProvider extends ServiceProvider
'clients:delete',
'organizations:view',
'organizations:update',
'organizations:delete',
'import',
'invitations:view',
'invitations:create',

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DeletionService
{
private UserService $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function deleteOrganization(Organization $organization, bool $inTransaction = true): void
{
if ($inTransaction) {
DB::transaction(function () use ($organization) {
$this->deleteOrganization($organization, false);
});
return;
}
Log::debug('Start deleting organization', [
'organization_id' => $organization->getKey(),
'name' => $organization->name,
'owner_id' => $organization->user_id,
]);
BeforeOrganizationDeletion::dispatch($organization);
// Delete all organization invitations
OrganizationInvitation::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all time entries
TimeEntry::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all tags
Tag::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all tasks
Task::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all project members
ProjectMember::query()->whereBelongsToOrganization($organization)->delete();
// Delete all projects
Project::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all clients
Client::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization
$organization->owner()
->where('current_team_id', $organization->getKey())
->update(['current_team_id' => null]);
$organization->users()
->where('current_team_id', $organization->getKey())
->update(['current_team_id' => null]);
// Delete all members
$users = $organization->users()
->with([
'currentOrganization',
])
->get();
$organization->users()->sync([]);
// Make sure all users have at least one organization
foreach ($users as $user) {
if ($user->is_placeholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
}
// Delete organization
$organization->delete();
Log::debug('Finished deleting organization', [
'organization_id' => $organization->getKey(),
'name' => $organization->name,
'owner_id' => $organization->user_id,
]);
}
/**
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers
*/
public function deleteUser(User $user, bool $inTransaction = true): void
{
if ($inTransaction) {
DB::transaction(function () use ($user) {
$this->deleteUser($user, false);
});
return;
}
Log::debug('Start deleting user', [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
]);
$members = Member::query()->whereBelongsTo($user, 'user')
->with([
'organization',
'user',
])
->get();
foreach ($members as $member) {
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers();
}
}
/** @var Member $member */
foreach ($members as $member) {
if ($member->role === Role::Owner->value) {
// Note: The member needs to be deleted first, otherwise the organization delete function will recreate a new personal organization for the user
$member->delete();
$this->deleteOrganization($member->organization, false);
} else {
$this->userService->makeMemberToPlaceholder($member);
}
}
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
$user->deleteProfilePhoto();
$user->delete();
Log::debug('Finished deleting user', [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
]);
}
}

View File

@@ -30,6 +30,11 @@ class PermissionStore
return false;
}
return $this->userHas($organization, $user, $permission);
}
public function userHas(Organization $organization, User $user, string $permission): bool
{
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
if (! $user->belongsToTeam($organization)) {
return false;

View File

@@ -28,6 +28,11 @@ class UserService
throw new \InvalidArgumentException('User is not a member of the organization');
}
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
}
private function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
{
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
@@ -47,6 +52,53 @@ class UserService
]);
}
public function makeMemberToPlaceholder(Member $member): void
{
$user = $member->user;
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->save();
$member->user()->associate($placeholderUser);
$member->role = Role::Placeholder->value;
$member->save();
$this->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->makeSureUserHasAtLeastOneOrganization($user);
}
public function makeSureUserHasAtLeastOneOrganization(User $user): void
{
if ($user->organizations()->count() > 0) {
return;
}
// Create a new organization
$organization = new Organization();
$organization->name = $user->name."'s Organization";
$organization->personal_team = true;
$organization->user_id = $user->id;
$organization->save();
// Attach the user to the organization
$organization->users()->attach($user, ['role' => Role::Owner->value]);
// Set the organization as the user's current organization
$user->currentOrganization()->associate($organization);
$user->save();
}
public function makeSureUserHasCurrentOrganization(User $user): void
{
if ($user->currentOrganization !== null) {
return;
}
$organization = $user->organizations()->first();
$user->currentOrganization()->associate($organization);
$user->save();
}
/**
* Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin.