From 73e2575e987f25beb9ed5e9988b0862af19a0500 Mon Sep 17 00:00:00 2001 From: snipe Date: Wed, 22 Jul 2026 17:16:35 +0100 Subject: [PATCH] Added transfer from one user to another --- .../Users/UserItemTransferController.php | 231 ++++++++++++++ .../Requests/TransferUserItemsRequest.php | 59 ++++ resources/lang/en-US/admin/users/general.php | 20 ++ resources/views/users/transfer.blade.php | 213 +++++++++++++ resources/views/users/view.blade.php | 8 + routes/web/users.php | 13 + tests/Feature/Users/TransferUserItemsTest.php | 289 ++++++++++++++++++ 7 files changed, 833 insertions(+) create mode 100644 app/Http/Controllers/Users/UserItemTransferController.php create mode 100644 app/Http/Requests/TransferUserItemsRequest.php create mode 100644 resources/views/users/transfer.blade.php create mode 100644 tests/Feature/Users/TransferUserItemsTest.php diff --git a/app/Http/Controllers/Users/UserItemTransferController.php b/app/Http/Controllers/Users/UserItemTransferController.php new file mode 100644 index 0000000000..1bb5c9840f --- /dev/null +++ b/app/Http/Controllers/Users/UserItemTransferController.php @@ -0,0 +1,231 @@ +authorize('view', $user); + $this->authorize('checkin', Asset::class); + $this->authorize('checkout', Asset::class); + + $assets = $user->assets() + ->with(['model.category', 'company']) + ->whereNull('deleted_at') + ->get(); + + $accessoryCheckouts = AccessoryCheckout::with(['accessory.category', 'accessory.company']) + ->where('assigned_to', $user->id) + ->where('assigned_type', User::class) + ->get(); + + $licenseSeats = LicenseSeat::with(['license.category', 'license.company']) + ->where('assigned_to', $user->id) + ->whereNull('asset_id') + ->get(); + + if ($assets->isEmpty() && $accessoryCheckouts->isEmpty() && $licenseSeats->isEmpty()) { + return redirect()->route('users.show', $user) + ->with('error', trans('admin/users/general.transfer.nothing_to_transfer')); + } + + return view('users.transfer', [ + 'sourceUser' => $user, + 'assets' => $assets, + 'accessoryCheckouts' => $accessoryCheckouts, + 'licenseSeats' => $licenseSeats, + ]); + } + + public function store(TransferUserItemsRequest $request, User $user): RedirectResponse + { + $validated = $request->validated(); + $target = User::findOrFail($validated['target_user_id']); + + $assetIds = $validated['asset_ids'] ?? []; + $accessoryCheckoutIds = $validated['accessory_checkout_ids'] ?? []; + $licenseSeatIds = $validated['license_seat_ids'] ?? []; + $note = $validated['note']; + + $result = DB::transaction(function () use ($user, $target, $assetIds, $accessoryCheckoutIds, $licenseSeatIds, $note) { + $assetsTransferred = 0; + $accessoriesTransferred = 0; + $licensesTransferred = 0; + $skipped = []; + + foreach ($assetIds as $assetId) { + $asset = Asset::find($assetId); + + if (! $asset || (int) $asset->assigned_to !== (int) $user->id || $asset->assigned_type !== User::class) { + $skipped[] = 'asset:'.$assetId; + + continue; + } + + if (! $asset->canCheckoutTo($target)) { + $skipped[] = 'asset:'.$assetId; + + continue; + } + + $this->checkInAsset($asset, $user, $note); + $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), null, $note); + $assetsTransferred++; + } + + foreach ($accessoryCheckoutIds as $checkoutId) { + $checkout = AccessoryCheckout::with('accessory')->find($checkoutId); + + if (! $checkout || (int) $checkout->assigned_to !== (int) $user->id || $checkout->assigned_type !== User::class) { + $skipped[] = 'accessory:'.$checkoutId; + + continue; + } + + $accessory = $checkout->accessory; + + if (! $accessory || ! $accessory->canCheckoutTo($target)) { + $skipped[] = 'accessory:'.$checkoutId; + + continue; + } + + $this->checkInAccessory($checkout, $accessory, $note); + $this->checkOutAccessory($accessory, $target, $note); + $accessoriesTransferred++; + } + + foreach ($licenseSeatIds as $seatId) { + $seat = LicenseSeat::with('license')->find($seatId); + + if (! $seat || (int) $seat->assigned_to !== (int) $user->id || $seat->asset_id !== null) { + $skipped[] = 'license:'.$seatId; + + continue; + } + + $license = $seat->license; + + // Non-reassignable licenses stay with the original assignee. + // Transferring one would defeat the whole point of the flag, + // so we skip and surface it in the warning bucket. + if (! $license || ! $license->reassignable || ! $license->canCheckoutTo($target)) { + $skipped[] = 'license:'.$seatId; + + continue; + } + + $this->transferLicenseSeat($seat, $user, $target, $note); + $licensesTransferred++; + } + + return compact('assetsTransferred', 'accessoriesTransferred', 'licensesTransferred', 'skipped'); + }); + + $flash = trans('admin/users/general.transfer.success', [ + 'assets' => $result['assetsTransferred'], + 'accessories' => $result['accessoriesTransferred'], + 'licenses' => $result['licensesTransferred'], + 'target' => $target->display_name, + ]); + + $redirect = redirect()->route('users.show', $target)->with('success', $flash); + + if (! empty($result['skipped'])) { + $redirect->with('warning', trans('admin/users/general.transfer.some_skipped', [ + 'count' => count($result['skipped']), + ])); + } + + return $redirect; + } + + private function checkInAsset(Asset $asset, User $source, ?string $note): void + { + $originalValues = $asset->getRawOriginal(); + $checkinAt = date('Y-m-d H:i:s'); + + $asset->expected_checkin = null; + $asset->last_checkin = $checkinAt; + $asset->accepted = null; + $asset->assignedTo()->dissociate(); + + $asset->licenseseats->each(function (LicenseSeat $seat) { + $seat->update(['assigned_to' => null]); + }); + + CheckoutAcceptance::pending() + ->where('checkoutable_type', Asset::class) + ->where('checkoutable_id', $asset->id) + ->get() + ->each(fn ($a) => $a->delete()); + + $asset->save(); + + event(new CheckoutableCheckedIn($asset, $source, auth()->user(), $note, $checkinAt, $originalValues)); + } + + private function checkInAccessory(AccessoryCheckout $checkout, Accessory $accessory, ?string $note): void + { + $source = $checkout->assignedTo; + + CheckoutAcceptance::pending() + ->where('checkoutable_type', Accessory::class) + ->where('checkoutable_id', $accessory->id) + ->where('assigned_to_id', $checkout->assigned_to) + ->get() + ->each(fn ($a) => $a->delete()); + + $checkout->delete(); + + event(new CheckoutableCheckedIn($accessory, $source, auth()->user(), $note, date('Y-m-d H:i:s'))); + } + + private function checkOutAccessory(Accessory $accessory, User $target, ?string $note): void + { + $newCheckout = new AccessoryCheckout([ + 'accessory_id' => $accessory->id, + 'assigned_to' => $target->id, + 'assigned_type' => User::class, + 'note' => $note, + ]); + $newCheckout->created_by = auth()->id(); + $newCheckout->save(); + + event(new CheckoutableCheckedOut($accessory, $target, auth()->user(), $note, [], 1, false)); + } + + private function transferLicenseSeat(LicenseSeat $seat, User $source, User $target, ?string $note): void + { + CheckoutAcceptance::pending() + ->where('checkoutable_type', License::class) + ->where('checkoutable_id', $seat->license_id) + ->where('assigned_to_id', $source->id) + ->get() + ->each(fn ($a) => $a->delete()); + + $seat->assigned_to = null; + $seat->save(); + event(new CheckoutableCheckedIn($seat, $source, auth()->user(), $note)); + + $seat->assigned_to = $target->id; + $seat->save(); + event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1, false)); + } +} diff --git a/app/Http/Requests/TransferUserItemsRequest.php b/app/Http/Requests/TransferUserItemsRequest.php new file mode 100644 index 0000000000..ae3f1d4573 --- /dev/null +++ b/app/Http/Requests/TransferUserItemsRequest.php @@ -0,0 +1,59 @@ +route('user'); + + return $sourceUser + && Gate::allows('view', $sourceUser) + && Gate::allows('checkin', Asset::class) + && Gate::allows('checkout', Asset::class); + } + + public function rules(): array + { + return [ + 'target_user_id' => ['required', 'integer', Rule::exists('users', 'id')->whereNull('deleted_at')], + 'asset_ids' => ['nullable', 'array'], + 'asset_ids.*' => ['integer'], + 'accessory_checkout_ids' => ['nullable', 'array'], + 'accessory_checkout_ids.*' => ['integer'], + 'license_seat_ids' => ['nullable', 'array'], + 'license_seat_ids.*' => ['integer'], + 'note' => ['required', 'string', 'max:1000'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $sourceUser = $this->route('user'); + if ($sourceUser && (int) $this->input('target_user_id') === (int) $sourceUser->id) { + $validator->errors()->add( + 'target_user_id', + trans('admin/users/general.transfer.target_same_as_source') + ); + } + + $assetIds = (array) $this->input('asset_ids', []); + $accessoryIds = (array) $this->input('accessory_checkout_ids', []); + $licenseSeatIds = (array) $this->input('license_seat_ids', []); + + if (empty($assetIds) && empty($accessoryIds) && empty($licenseSeatIds)) { + $validator->errors()->add( + 'asset_ids', + trans('admin/users/general.transfer.nothing_selected') + ); + } + }); + } +} diff --git a/resources/lang/en-US/admin/users/general.php b/resources/lang/en-US/admin/users/general.php index 95dcbb8029..f77c4ddbd9 100644 --- a/resources/lang/en-US/admin/users/general.php +++ b/resources/lang/en-US/admin/users/general.php @@ -74,4 +74,24 @@ return [ 'impersonating_banner_title' => 'Impersonating:', 'impersonating_banner_text' => 'You are currently logged in as :name. Anything you do will be recorded as if :name did it. Your real account is :impersonator.', 'impersonating_stop_link' => 'Switch back to :name', + 'transfer' => [ + 'title' => 'Transfer Items', + 'button' => 'Transfer Items', + 'button_tooltip' => 'Check in and re-check out this user\'s items to another user in one step.', + 'heading' => 'Transfer items from :name', + 'intro' => 'Pick a destination user and confirm which items to transfer. Each selected item is checked in from the source user and immediately checked out to the destination. Items requiring acceptance will re-fire the acceptance flow for the destination user.', + 'target_user' => 'Transfer to', + 'assets' => 'Assets', + 'accessories' => 'Accessories', + 'licenses' => 'Licenses', + 'non_reassignable' => 'Non-reassignable', + 'note' => 'Reason for transfer', + 'note_help' => 'A short reason for this transfer, recorded on both the checkin and checkout audit-log entries. Required so future readers can see why the items moved.', + 'submit' => 'Transfer selected items', + 'nothing_to_transfer' => 'This user has no assets, accessories, or licenses checked out to transfer.', + 'nothing_selected' => 'Select at least one item to transfer.', + 'target_same_as_source' => 'The destination user must be different from the source user.', + 'success' => 'Transferred :assets asset(s), :accessories accessory checkout(s), and :licenses license seat(s) to :target.', + 'some_skipped' => ':count item(s) were skipped, likely because they no longer belong to the source user, violate company scoping, or are non-reassignable.', + ], ]; diff --git a/resources/views/users/transfer.blade.php b/resources/views/users/transfer.blade.php new file mode 100644 index 0000000000..b7df5e530f --- /dev/null +++ b/resources/views/users/transfer.blade.php @@ -0,0 +1,213 @@ +@extends('layouts/default') + +@section('title') + {{ trans('admin/users/general.transfer.title') }} + @parent +@stop + +@section('header_right') + +@endsection + +@section('content') + + + + + + + + +

{{ trans('admin/users/general.transfer.intro') }}

+ + + + @if ($assets->isNotEmpty()) + + + + + + + + + + + + + @foreach ($assets as $asset) + + + + + + + @endforeach + +
+ + {{ trans('general.asset_tag') }}{{ trans('general.name') }}{{ trans('general.category') }}
+ + {{ $asset->asset_tag }}{{ $asset->name ?: ($asset->model->name ?? '') }}{{ $asset->model?->category?->name }}
+
+
+ @endif + + @if ($accessoryCheckouts->isNotEmpty()) + + + + + + + + + + + + @foreach ($accessoryCheckouts as $checkout) + + + + + + @endforeach + +
+ + {{ trans('general.name') }}{{ trans('general.category') }}
+ + {{ $checkout->accessory?->name }}{{ $checkout->accessory?->category?->name }}
+
+
+ @endif + + @if ($licenseSeats->isNotEmpty()) + + + + + + + + + + + + @foreach ($licenseSeats as $seat) + ! $seat->license?->reassignable])> + + + + + @endforeach + +
+ + {{ trans('general.name') }}{{ trans('general.category') }}
+ license?->reassignable) + @disabled(! $seat->license?->reassignable) + /> + + {{ $seat->license?->name }} + @if ($seat->license && ! $seat->license->reassignable) + {{ trans('admin/users/general.transfer.non_reassignable') }} + @endif + {{ $seat->license?->category?->name }}
+
+
+ @endif + + + + + + + +
+ + + {{ trans('button.cancel') }} + + + +
+ +
+ + + + + + +
+ +@stop diff --git a/resources/views/users/view.blade.php b/resources/views/users/view.blade.php index bb77c742c7..d979039cb1 100755 --- a/resources/views/users/view.blade.php +++ b/resources/views/users/view.blade.php @@ -616,6 +616,14 @@ @endif + @can('checkout', \App\Models\Asset::class) + @if (($user->assets()->whereNull('deleted_at')->count() + $user->accessories()->count() + $user->licenses()->count()) > 0) + + + + @endif + @endcan + @if(!empty($user->email) && ($user->allAssignedCount() != '0'))
diff --git a/routes/web/users.php b/routes/web/users.php index d0124a73ac..031a00857e 100644 --- a/routes/web/users.php +++ b/routes/web/users.php @@ -161,6 +161,19 @@ Route::group(['prefix' => 'users', 'middleware' => ['auth']], function () { ] )->name('users/bulkeditsave'); + Route::get( + '{user}/transfer', + [Users\UserItemTransferController::class, 'show'], + )->name('users.transfer.show') + ->breadcrumbs(fn (Trail $trail, $user) => $trail + ->parent('users.show', $user) + ->push(trans('admin/users/general.transfer.title'), route('users.transfer.show', $user))); + + Route::post( + '{user}/transfer', + [Users\UserItemTransferController::class, 'store'], + )->name('users.transfer.store'); + }); Route::resource('users', Users\UsersController::class, [ diff --git a/tests/Feature/Users/TransferUserItemsTest.php b/tests/Feature/Users/TransferUserItemsTest.php new file mode 100644 index 0000000000..81ea2bef7a --- /dev/null +++ b/tests/Feature/Users/TransferUserItemsTest.php @@ -0,0 +1,289 @@ +create(); + $source = User::factory()->create(); + + $this->get(route('users.transfer.show', $source)) + ->assertRedirect(route('login')); + } + + public function test_transfer_page_requires_checkout_permission(): void + { + $source = User::factory()->create(); + + $this->actingAs(User::factory()->viewUsers()->create()) + ->get(route('users.transfer.show', $source)) + ->assertForbidden(); + } + + public function test_transfer_page_renders_when_source_has_items(): void + { + $source = User::factory()->create(); + Asset::factory()->create(['assigned_to' => $source->id, 'assigned_type' => User::class]); + + $this->actingAs($this->transferActor()) + ->get(route('users.transfer.show', $source)) + ->assertOk() + ->assertViewIs('users.transfer'); + } + + public function test_transfer_page_redirects_when_source_has_no_items(): void + { + $source = User::factory()->create(); + + $this->actingAs($this->transferActor()) + ->get(route('users.transfer.show', $source)) + ->assertRedirect(route('users.show', $source)); + } + + public function test_transfer_moves_asset_from_source_to_target(): void + { + $source = User::factory()->create(); + $target = User::factory()->create(); + $asset = Asset::factory()->create([ + 'assigned_to' => $source->id, + 'assigned_type' => User::class, + ]); + + $response = $this->actingAs($this->transferActor()) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'asset_ids' => [$asset->id], + 'note' => 'employee offboarding', + ]); + + $response->assertRedirect(route('users.show', $target)); + + $asset->refresh(); + $this->assertSame($target->id, $asset->assigned_to); + $this->assertSame(User::class, $asset->assigned_type); + + // Both a checkin and a checkout should be logged for this transfer + // so the audit trail shows the full move rather than a single event. + $this->assertDatabaseHas('action_logs', [ + 'action_type' => 'checkin from', + 'target_id' => $source->id, + 'target_type' => User::class, + 'item_id' => $asset->id, + 'item_type' => Asset::class, + ]); + $this->assertDatabaseHas('action_logs', [ + 'action_type' => 'checkout', + 'target_id' => $target->id, + 'target_type' => User::class, + 'item_id' => $asset->id, + 'item_type' => Asset::class, + ]); + } + + public function test_transfer_moves_accessory_from_source_to_target(): void + { + $source = User::factory()->create(); + $target = User::factory()->create(); + $accessory = Accessory::factory()->create(); + $checkout = AccessoryCheckout::create([ + 'accessory_id' => $accessory->id, + 'assigned_to' => $source->id, + 'assigned_type' => User::class, + 'created_by' => User::factory()->create()->id, + ]); + + $this->actingAs($this->transferActor()) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'accessory_checkout_ids' => [$checkout->id], + 'note' => 'employee offboarding', + ]) + ->assertRedirect(route('users.show', $target)); + + $this->assertDatabaseMissing('accessories_checkout', ['id' => $checkout->id]); + $this->assertDatabaseHas('accessories_checkout', [ + 'accessory_id' => $accessory->id, + 'assigned_to' => $target->id, + 'assigned_type' => User::class, + ]); + } + + public function test_transfer_moves_reassignable_license_seat_from_source_to_target(): void + { + $source = User::factory()->create(); + $target = User::factory()->create(); + $license = License::factory()->create(['reassignable' => 1]); + $seat = LicenseSeat::factory()->assignedToUser($source)->create(['license_id' => $license->id]); + + $this->actingAs($this->transferActor()) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'license_seat_ids' => [$seat->id], + 'note' => 'transferring license seat', + ]) + ->assertRedirect(route('users.show', $target)); + + $seat->refresh(); + $this->assertSame($target->id, $seat->assigned_to); + } + + public function test_transfer_skips_non_reassignable_license_seat(): void + { + $source = User::factory()->create(); + $target = User::factory()->create(); + $license = License::factory()->create(['reassignable' => 0]); + $seat = LicenseSeat::factory()->assignedToUser($source)->create(['license_id' => $license->id]); + + // Non-reassignable licenses stay put by design. The seat must NOT + // move even if the client somehow submits its id. + $this->actingAs($this->transferActor()) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'license_seat_ids' => [$seat->id], + 'note' => 'attempt to move non-reassignable license', + ]) + ->assertRedirect(route('users.show', $target)); + + $seat->refresh(); + $this->assertSame($source->id, $seat->assigned_to); + } + + public function test_transfer_leaves_unselected_items_alone(): void + { + $source = User::factory()->create(); + $target = User::factory()->create(); + + $selectedAsset = Asset::factory()->create([ + 'assigned_to' => $source->id, + 'assigned_type' => User::class, + ]); + $unselectedAsset = Asset::factory()->create([ + 'assigned_to' => $source->id, + 'assigned_type' => User::class, + ]); + + $this->actingAs($this->transferActor()) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'asset_ids' => [$selectedAsset->id], + 'note' => 'transferring one item', + ]); + + $selectedAsset->refresh(); + $unselectedAsset->refresh(); + + $this->assertSame($target->id, $selectedAsset->assigned_to); + $this->assertSame($source->id, $unselectedAsset->assigned_to); + } + + public function test_transfer_rejects_same_source_and_target(): void + { + $source = User::factory()->create(); + $asset = Asset::factory()->create([ + 'assigned_to' => $source->id, + 'assigned_type' => User::class, + ]); + + $this->actingAs($this->transferActor()) + ->from(route('users.transfer.show', $source)) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $source->id, + 'asset_ids' => [$asset->id], + 'note' => 'accidental self-target', + ]) + ->assertRedirect(route('users.transfer.show', $source)) + ->assertSessionHasErrors('target_user_id'); + + $asset->refresh(); + $this->assertSame($source->id, $asset->assigned_to); + } + + public function test_transfer_rejects_empty_selection(): void + { + $source = User::factory()->create(); + $target = User::factory()->create(); + + $this->actingAs($this->transferActor()) + ->from(route('users.transfer.show', $source)) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'note' => 'nothing selected', + ]) + ->assertRedirect(route('users.transfer.show', $source)) + ->assertSessionHasErrors('asset_ids'); + } + + public function test_transfer_rejects_empty_note(): void + { + $source = User::factory()->create(); + $target = User::factory()->create(); + $asset = Asset::factory()->create([ + 'assigned_to' => $source->id, + 'assigned_type' => User::class, + ]); + + $this->actingAs($this->transferActor()) + ->from(route('users.transfer.show', $source)) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'asset_ids' => [$asset->id], + // note deliberately omitted + ]) + ->assertRedirect(route('users.transfer.show', $source)) + ->assertSessionHasErrors('note'); + + $asset->refresh(); + $this->assertSame($source->id, $asset->assigned_to); + } + + public function test_transfer_ignores_asset_not_actually_assigned_to_source(): void + { + // Someone tampering with the form to pass an asset ID that + // doesn't belong to the source user should not be able to + // hijack the transfer flow to reassign arbitrary assets. + $source = User::factory()->create(); + $target = User::factory()->create(); + $otherOwner = User::factory()->create(); + + $sourceAsset = Asset::factory()->create([ + 'assigned_to' => $source->id, + 'assigned_type' => User::class, + ]); + $foreignAsset = Asset::factory()->create([ + 'assigned_to' => $otherOwner->id, + 'assigned_type' => User::class, + ]); + + $this->actingAs($this->transferActor()) + ->post(route('users.transfer.store', $source), [ + 'target_user_id' => $target->id, + 'asset_ids' => [$sourceAsset->id, $foreignAsset->id], + 'note' => 'attempting cross-user transfer', + ]); + + $sourceAsset->refresh(); + $foreignAsset->refresh(); + + $this->assertSame($target->id, $sourceAsset->assigned_to); + $this->assertSame($otherOwner->id, $foreignAsset->assigned_to); + } + + private function transferActor(): User + { + return User::factory() + ->viewUsers() + ->checkinAssets() + ->checkoutAssets() + ->create(); + } +}