Merge remote-tracking branch 'origin/master' into develop

# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
This commit is contained in:
snipe
2026-04-16 14:13:02 +01:00
34 changed files with 1216 additions and 83 deletions
@@ -0,0 +1,109 @@
<?php
namespace App\Actions\Breadcrumbs;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tabuna\Breadcrumbs\Trail;
final class BuildAcceptanceBreadcrumbs
{
public static function forAcceptance(Trail $trail, CheckoutAcceptance|int|string $acceptance): void
{
$acceptance = self::resolveAcceptance($acceptance);
$trail->parent('home');
if (! $acceptance instanceof CheckoutAcceptance) {
self::appendProfileContext($trail);
return;
}
if (! self::isSignInPlaceFlow($acceptance)) {
self::appendProfileContext($trail);
$trail->push(trans('general.accept_item'), route('account.accept.item', $acceptance));
return;
}
self::appendCheckoutFlowContext($trail, $acceptance);
$trail->push(self::buildSignInPlaceLabel($acceptance));
}
private static function resolveAcceptance(CheckoutAcceptance|int|string $acceptance): ?CheckoutAcceptance
{
if ($acceptance instanceof CheckoutAcceptance) {
return $acceptance;
}
if (is_numeric($acceptance)) {
return CheckoutAcceptance::find((int) $acceptance);
}
return null;
}
private static function isSignInPlaceFlow(CheckoutAcceptance $acceptance): bool
{
return (int) session('sign_in_place_acceptance_id') === (int) $acceptance->id;
}
private static function appendProfileContext(Trail $trail): void
{
$trail->push(trans('general.profile'), route('account'));
$trail->push(trans('general.accept_items'), route('account.accept'));
}
private static function appendCheckoutFlowContext(Trail $trail, CheckoutAcceptance $acceptance): void
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
$trail->push(trans('general.assets'), route('hardware.index'));
$trail->push($checkoutable->display_name ?? trans('general.asset'), route('hardware.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof LicenseSeat) {
$license = $checkoutable->license;
if ($license instanceof License) {
$trail->push(trans('general.licenses'), route('licenses.index'));
$trail->push($license->display_name ?? trans('general.license'), route('licenses.show', $license));
$trail->push(trans('general.checkout'));
}
return;
}
if ($checkoutable instanceof Consumable) {
$trail->push(trans('general.consumables'), route('consumables.index'));
$trail->push($checkoutable->display_name ?? trans('general.consumable'), route('consumables.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof Accessory) {
$trail->push(trans('general.accessories'), route('accessories.index'));
$trail->push($checkoutable->display_name ?? trans('general.accessory'), route('accessories.show', $checkoutable));
$trail->push(trans('general.checkout'));
}
}
private static function buildSignInPlaceLabel(CheckoutAcceptance $acceptance): string
{
if ($acceptance->assignedTo instanceof User) {
return sprintf('%s for %s', trans('general.sign_in_place'), $acceptance->assignedTo->display_name);
}
return trans('general.sign_in_place');
}
}
+4 -1
View File
@@ -22,12 +22,14 @@ class CheckoutableCheckedOut
public int $quantity;
public bool $signInPlace;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1)
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1, bool $signInPlace = false)
{
$this->checkoutable = $checkoutable;
$this->checkedOutTo = $checkedOutTo;
@@ -35,5 +37,6 @@ class CheckoutableCheckedOut
$this->note = $note;
$this->originalValues = $originalValues;
$this->quantity = $quantity;
$this->signInPlace = $signInPlace;
}
}
+13 -3
View File
@@ -1629,10 +1629,20 @@ class Helper
// return to assignment target
if ($redirect_option == 'target') {
$userId = $request->assigned_user ?? $checkedInFrom;
$locationId = $request->assigned_location ?? $checkedInFrom;
$assetId = $request->assigned_asset ?? $checkedInFrom;
return match ($checkout_to_type) {
'user' => redirect()->route('users.show', $request->assigned_user ?? $checkedInFrom),
'location' => redirect()->route('locations.show', $request->assigned_location ?? $checkedInFrom),
'asset' => redirect()->route('hardware.show', $request->assigned_asset ?? $checkedInFrom),
'user' => $userId
? redirect()->route('users.show', $userId)
: redirect()->route('users.index'),
'location' => $locationId
? redirect()->route('locations.show', $locationId)
: redirect()->route('locations.index'),
'asset' => $assetId
? redirect()->route('hardware.show', $assetId)
: redirect()->route('hardware.index'),
};
}
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
@@ -88,12 +89,53 @@ class AccessoryCheckoutController extends Controller
$request->input('note'),
[],
$accessory->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
$request->request->add(['assigned_to' => $target->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested for a user checkout, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && ! in_array($request->input('checkout_to_type'), ['asset', 'location'], true)) {
$targetUser = User::find($target->id);
if (! $targetUser instanceof User) {
return redirect()->route('accessories.checkout.show', $accessory)
->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
}
$acceptance = CheckoutAcceptance::where('checkoutable_type', Accessory::class)
->where('checkoutable_id', $accessory->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($accessory);
$acceptance->assignedTo()->associate($targetUser);
$acceptance->qty = $accessory->checkout_qty;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $accessory->id,
'sign_in_place_resource_type' => 'Accessories',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/accessories/message.checkout.success'));
}
// Redirect to the new accessory page
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
@@ -7,8 +7,14 @@ use App\Events\CheckoutDeclined;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Mail\CheckoutAcceptanceResponseMail;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\AcceptanceItemAcceptedNotification;
@@ -40,19 +46,32 @@ class AcceptanceController extends Controller
*
* @param int $id
*/
public function create($id): View|RedirectResponse
public function create(Request $request, $id): View|RedirectResponse
{
$currentUser = auth()->user();
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (is_null($acceptance)) {
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
}
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
@@ -60,7 +79,10 @@ class AcceptanceController extends Controller
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
}
return view('account/accept.create', compact('acceptance'));
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
$checkedOutBy = $this->resolveCheckoutActorName($acceptance);
return view('account/accept.create', compact('acceptance', 'isSignInPlaceAdminFlow', 'checkedOutAt', 'checkedOutBy'));
}
/**
@@ -70,20 +92,36 @@ class AcceptanceController extends Controller
*/
public function store(Request $request, $id): RedirectResponse
{
$currentUser = auth()->user();
if (! $acceptance = CheckoutAcceptance::find($id)) {
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
}
$assigned_user = User::find($acceptance->assigned_to_id);
$assignedUser = User::find($acceptance->assigned_to_id);
$settings = Setting::getSettings();
$requiresSignature = (string) $settings->require_accept_signature === '1';
$sig_filename = '';
$encodedSignatureImage = null;
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
@@ -112,14 +150,25 @@ class AcceptanceController extends Controller
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
// If signatures are required, make sure we have one
if (Setting::getSettings()->require_accept_signature == '1') {
if ($requiresSignature) {
// The item was accepted, check for a signature
if ($request->filled('signature_output')) {
$sig_filename = 'siglog-'.Str::uuid().'-'.date('Y-m-d-his').'.png';
$data_uri = $request->input('signature_output');
$encoded_image = explode(',', $data_uri);
$decoded_image = base64_decode($encoded_image[1]);
$dataUri = (string) $request->input('signature_output');
$encodedSignatureImage = Str::contains($dataUri, ',')
? Str::after($dataUri, ',')
: $dataUri;
$decoded_image = base64_decode($encodedSignatureImage, true);
if ($decoded_image === false) {
return redirect()->back()->with('error', trans('general.shitty_browser'));
}
$decoded_image = $this->flattenSignatureBackgroundToWhite($decoded_image);
$encodedSignatureImage = base64_encode($decoded_image);
Storage::put('private_uploads/signatures/'.$sig_filename, (string) $decoded_image);
// No image data is present, kick them back.
@@ -148,18 +197,18 @@ class AcceptanceController extends Controller
'check_out_date' => Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false),
'accepted_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'declined_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'assigned_to' => $assigned_user->display_name,
'email' => $assigned_user->email,
'employee_num' => $assigned_user->employee_num,
'assigned_to' => $assignedUser->display_name,
'email' => $assignedUser->email,
'employee_num' => $assignedUser->employee_num,
'site_name' => $settings->site_name,
'company_name' => $item->company?->name ?? $settings->site_name,
'signature' => (($sig_filename && array_key_exists('1', $encoded_image))) ? $encoded_image[1] : null,
'signature' => ($sig_filename !== '') ? $encodedSignatureImage : null,
'logo' => ($encoded_logo) ?? null,
'date_settings' => $settings->date_display_format,
'qty' => $acceptance->qty ?? 1,
];
if ($request->input('asset_acceptance') == 'accepted') {
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
@@ -171,12 +220,12 @@ class AcceptanceController extends Controller
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
// Send the PDF to the signing user
if (($request->input('send_copy') == '1') && ($assigned_user->email != '')) {
if (($request->input('send_copy') === '1') && ($assignedUser->email !== '')) {
// Add the attachment for the signing user into the $data array
$data['file'] = $pdf_filename;
try {
$assigned_user->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assigned_user->locale));
$assignedUser->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assignedUser->locale));
} catch (Exception $e) {
Log::warning($e);
}
@@ -215,7 +264,7 @@ class AcceptanceController extends Controller
$recipient,
$request->input('asset_acceptance') === 'accepted',
));
Log::debug('Send email notification sucess on checkout acceptance response.');
Log::debug('Send email notification success on checkout acceptance response.');
}
} catch (Exception $e) {
Log::error($e->getMessage());
@@ -223,7 +272,163 @@ class AcceptanceController extends Controller
}
}
if ($isSignInPlaceAdminFlow) {
$request->request->add(['assigned_user' => $assignedUser?->id]);
$redirect = Helper::getRedirectOption(
$request,
session('sign_in_place_item_id'),
session('sign_in_place_resource_type'),
);
session()->forget([
'sign_in_place_acceptance_id',
'sign_in_place_item_id',
'sign_in_place_resource_type',
]);
return $redirect->with('success', $return_msg);
}
return redirect()->to('account/accept')->with('success', $return_msg);
}
private function isSignInPlaceAdminFlow(CheckoutAcceptance $acceptance): bool
{
$currentUser = auth()->user();
return ((int) session('sign_in_place_acceptance_id') === (int) $acceptance->id)
&& ($currentUser?->can('checkout', $acceptance->checkoutable));
}
private function resolveCheckoutActorName(CheckoutAcceptance $acceptance): ?string
{
[$itemType, $itemId] = $this->resolveCheckoutLogItem($acceptance);
$checkoutLog = Actionlog::query()
->where('action_type', 'checkout')
->where('item_type', $itemType)
->where('item_id', $itemId)
->where('target_type', User::class)
->where('target_id', $acceptance->assigned_to_id)
->where('created_at', '<=', $acceptance->created_at->copy()->addMinutes(5))
->latest('id')
->first();
return $checkoutLog?->adminuser?->display_name;
}
/**
* Action logs normalize license seat checkouts to the parent license.
*
* @return array{0: class-string, 1: int}
*/
private function resolveCheckoutLogItem(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof LicenseSeat) {
return [License::class, (int) $checkoutable->license_id];
}
return [$acceptance->checkoutable_type, (int) $acceptance->checkoutable_id];
}
private function isStaleSignInPlaceAdminAttempt(CheckoutAcceptance $acceptance, User $currentUser): bool
{
$redirectOption = session('redirect_option');
$checkoutToType = session('checkout_to_type');
if (session('sign_in_place') !== true) {
return false;
}
if ($redirectOption === null) {
return false;
}
if ($redirectOption === 'target' && $checkoutToType === 'user' && empty($acceptance->assigned_to_id)) {
return false;
}
return ! $acceptance->isCheckedOutTo($currentUser)
&& $currentUser->can('checkout', $acceptance->checkoutable)
&& ($checkoutToType === 'user');
}
private function redirectToIntendedSignInPlaceDestination(Request $request, CheckoutAcceptance $acceptance): RedirectResponse
{
if (empty($acceptance->assigned_to_id)) {
return redirect()->route('account.accept');
}
[$itemId, $resourceType] = $this->resolveRedirectTarget($acceptance);
$request->request->add(['assigned_user' => $acceptance->assigned_to_id]);
return Helper::getRedirectOption($request, $itemId, $resourceType);
}
/**
* @return array{0: int, 1: string}
*/
private function resolveRedirectTarget(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
return [(int) $checkoutable->id, 'Assets'];
}
if ($checkoutable instanceof Accessory) {
return [(int) $checkoutable->id, 'Accessories'];
}
if ($checkoutable instanceof Consumable) {
return [(int) $checkoutable->id, 'Consumables'];
}
if ($checkoutable instanceof LicenseSeat) {
return [(int) $checkoutable->license_id, 'Licenses'];
}
return [(int) $acceptance->checkoutable_id, session('sign_in_place_resource_type', 'Assets')];
}
private function flattenSignatureBackgroundToWhite(string $signatureBinary): string
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagecreatetruecolor')) {
return $signatureBinary;
}
$source = @imagecreatefromstring($signatureBinary);
if ($source === false) {
return $signatureBinary;
}
$width = imagesx($source);
$height = imagesy($source);
$flattened = imagecreatetruecolor($width, $height);
if ($flattened === false) {
imagedestroy($source);
return $signatureBinary;
}
$white = imagecolorallocate($flattened, 255, 255, 255);
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
imagecopy($flattened, $source, 0, 0, 0, 0, $width, $height);
ob_start();
imagepng($flattened);
$output = ob_get_clean();
imagedestroy($source);
imagedestroy($flattened);
return is_string($output) ? $output : $signatureBinary;
}
}
@@ -8,7 +8,9 @@ use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
@@ -122,9 +124,43 @@ class AssetCheckoutController extends Controller
}
}
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'), null, $request->boolean('sign_in_place'))) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person. The signature is
// attributed to the target user, not the admin.
if ($request->boolean('sign_in_place') && $target instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->where('assigned_to_id', $target->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($asset);
$acceptance->assignedTo()->associate($target);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $asset->id,
'sign_in_place_resource_type' => 'Assets',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/hardware/message.checkout.success'));
}
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'))) {
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('success', trans('admin/hardware/message.checkout.success'));
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Consumables;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -116,12 +117,46 @@ class ConsumableCheckoutController extends Controller
$request->input('note'),
[],
$consumable->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => 'user']);
$request->request->add(['assigned_user' => $user->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested, redirect to the acceptance/signature page
// so the user can sign in person. The signature is attributed to the target user.
if ($request->boolean('sign_in_place')) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Consumable::class)
->where('checkoutable_id', $consumable->id)
->where('assigned_to_id', $user->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($consumable);
$acceptance->assignedTo()->associate($user);
$acceptance->qty = $quantity;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $consumable->id,
'sign_in_place_resource_type' => 'Consumables',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/consumables/message.checkout.success'));
}
// Redirect to the new consumable page
return Helper::getRedirectOption($request, $consumable->id, 'Consumables')
@@ -7,6 +7,7 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\LicenseCheckoutRequest;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
@@ -101,17 +102,53 @@ class LicenseCheckoutController extends Controller
session()->put(['checkout_to_type' => 'asset']);
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'asset']);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'asset',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
} elseif ($request->filled('assigned_to')) {
session()->put(['checkout_to_type' => 'user']);
$checkoutTarget = $this->checkoutToUser($licenseSeat);
$request->request->add(['assigned_user' => $checkoutTarget->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'user']);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'user',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
}
if ($checkoutTarget) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && $checkoutTarget instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->where('checkoutable_id', $licenseSeat->id)
->where('assigned_to_id', $checkoutTarget->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($licenseSeat);
$acceptance->assignedTo()->associate($checkoutTarget);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $license->id,
'sign_in_place_resource_type' => 'Licenses',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/licenses/message.checkout.success'));
}
return Helper::getRedirectOption($request, $license->id, 'Licenses')
->with('success', trans('admin/licenses/message.checkout.success'));
}
@@ -150,7 +187,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = $target->assigned_to;
}
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
@@ -167,7 +204,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = request('assigned_to');
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
+15
View File
@@ -40,6 +40,7 @@ use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Osama\LaravelTeamsNotification\TeamsNotification;
class CheckoutableListener
{
private array $skipNotificationsFor = [
@@ -80,6 +81,11 @@ class CheckoutableListener
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if ($this->shouldSkipInitialAcceptanceEmail($event, $acceptance)) {
$shouldSendEmailToUser = false;
$shouldSendEmailToAlertAddress = false;
}
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
return;
}
@@ -480,6 +486,15 @@ class CheckoutableListener
return false;
}
private function shouldSkipInitialAcceptanceEmail(CheckoutableCheckedOut $event, ?CheckoutAcceptance $acceptance): bool
{
if (! $event->signInPlace) {
return false;
}
return ($acceptance instanceof CheckoutAcceptance) || ! empty($event->checkoutable->getEula());
}
private function shouldSendEmailToAlertAddress($acceptance = null): bool
{
if (Context::get('action') === 'bulk_asset_checkout') {
+7
View File
@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Models\Setting;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -45,4 +46,10 @@ class CategoryEditForm extends Component
{
return (bool) $this->useDefaultEula;
}
#[Computed]
public function isGlobalSignatureRequired(): bool
{
return (string) Setting::getSettings()->require_accept_signature === '1';
}
}
+2 -2
View File
@@ -516,7 +516,7 @@ class Asset extends Depreciable
*
* @return bool
*/
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null)
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null, bool $signInPlace = false)
{
if (! $target) {
return false;
@@ -560,7 +560,7 @@ class Asset extends Depreciable
} else {
$checkedOutBy = auth()->user();
}
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues));
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues, 1, $signInPlace));
$this->increment('checkout_counter', 1);
+23 -7
View File
@@ -52496,7 +52496,11 @@ $(function () {
// This handles the radio button selectors for the checkout-to-foo options
// on asset checkout and also on asset edit
$(function () {
$('input[name=checkout_to_type]').on("change", function () {
var checkoutToTypeInputs = $('input[name=checkout_to_type]');
if (!checkoutToTypeInputs.length) {
return;
}
function syncCheckoutToTypeUi(resetSelections) {
var assignto_type = $('input[name=checkout_to_type]:checked').val();
var userid = $('#assigned_user option:selected').val();
if (assignto_type == 'asset') {
@@ -52505,16 +52509,20 @@ $(function () {
$('#assigned_user').hide();
$('#assigned_location').hide();
$('.notification-callout').fadeOut();
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
if (resetSelections) {
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else if (assignto_type == 'location') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').hide();
$('#assigned_user').hide();
$('#assigned_location').show();
$('.notification-callout').fadeOut();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else {
$('#assigned_asset').hide();
$('#assigned_user').show();
@@ -52523,10 +52531,18 @@ $(function () {
$('#current_assets_box').fadeIn();
}
$('.notification-callout').fadeIn();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
}
}
}
checkoutToTypeInputs.on('change', function () {
syncCheckoutToTypeUi(true);
});
// Apply the current radio selection on initial render.
syncCheckoutToTypeUi(false);
});
// ------------------------------------------------
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"/js/dist/all.js": "/js/dist/all.js?id=4a454568215784cca2f6e2fc6d9189dc",
"/js/dist/all.js": "/js/dist/all.js?id=4f5a712e780c903c0d996539233b4f78",
"/css/build/overrides.css": "/css/build/overrides.css?id=c173dd71d56c1089bf560a849586d93e",
"/css/build/app.css": "/css/build/app.css?id=63ef76491d01db361ad53cf1c8c7114f",
"/css/build/AdminLTE.css": "/css/build/AdminLTE.css?id=ee0ed88465dd878588ed044eefb67723",
+27 -10
View File
@@ -416,7 +416,13 @@ $(function () {
// This handles the radio button selectors for the checkout-to-foo options
// on asset checkout and also on asset edit
$(function() {
$('input[name=checkout_to_type]').on("change",function () {
var checkoutToTypeInputs = $('input[name=checkout_to_type]');
if (!checkoutToTypeInputs.length) {
return;
}
function syncCheckoutToTypeUi(resetSelections) {
var assignto_type = $('input[name=checkout_to_type]:checked').val();
var userid = $('#assigned_user option:selected').val();
@@ -427,9 +433,10 @@ $(function () {
$('#assigned_location').hide();
$('.notification-callout').fadeOut();
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
if (resetSelections) {
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else if (assignto_type == 'location') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').hide();
@@ -437,10 +444,11 @@ $(function () {
$('#assigned_location').show();
$('.notification-callout').fadeOut();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
} else {
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else {
$('#assigned_asset').hide();
$('#assigned_user').show();
$('#assigned_location').hide();
@@ -449,10 +457,19 @@ $(function () {
}
$('.notification-callout').fadeIn();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
}
}
}
checkoutToTypeInputs.on('change', function () {
syncCheckoutToTypeUi(true);
});
// Apply the current radio selection on initial render.
syncCheckoutToTypeUi(false);
});
@@ -9,7 +9,7 @@ return [
'edit' => 'Edit Accessory',
'eula_text' => 'Category EULA',
'eula_text_help' => 'This field allows you to customize your EULAs for specific types of assets. If you only have one EULA for all of your assets, you can check the box below to use the primary default.',
'require_acceptance' => 'Require users to confirm acceptance of assets in this category.',
'require_acceptance' => 'Require users to confirm acceptance of item in this category.',
'no_default_eula' => 'No primary default EULA found. Add one in Settings.',
'total' => 'Total',
'remaining' => 'Avail',
@@ -17,6 +17,7 @@ return [
'name' => 'Category Name',
'require_acceptance' => 'Require users to confirm acceptance of assets in this category.',
'required_acceptance' => 'This user will be emailed with a link to confirm acceptance of this item.',
'global_signature_required_notice' => 'User signatures are currently required globally via the admin settings, so signatures will still be required regardless of this category setting if the item is checked out to a user (versus a location, etc).',
'required_eula' => 'This user will be emailed a copy of the EULA',
'no_default_eula' => 'No primary default EULA found. Add one in Settings.',
'update' => 'Update Category',
@@ -309,7 +309,7 @@ return [
'two_factor_enabled_edit_not_allowed' => 'Your administrator does not permit you to edit this setting.',
'two_factor_enrollment_text' => "Two factor authentication is required, however your device has not been enrolled yet. Open your Google Authenticator app and scan the QR code below to enroll your device. Once you've enrolled your device, enter the code below",
'require_accept_signature' => 'Require Signature',
'require_accept_signature_help_text' => 'Enabling this feature will require users to physically sign off on accepting an asset.',
'require_accept_signature_help_text' => 'Enabling this feature will require users to physically sign off on accepting items. This will override any category-specific signature requirements.',
'require_checkinout_notes' => 'Require Notes on Checkin/Checkout',
'require_checkinout_notes_help_text' => 'Enabling this feature will require the note fields to be populated when checking in or checking out an asset.',
'left' => 'left',
+1 -1
View File
@@ -44,7 +44,7 @@ return [
'delete_has_users_var' => 'This user still manages another user. Please select another manager for that user first.|This user still manages :count users. Please select another manager for them first.',
'unsuspend' => 'There was an issue unsuspending the user. Please try again.',
'import' => 'There was an issue importing users. Please try again.',
'asset_already_accepted' => 'This asset has already been accepted.',
'asset_already_accepted' => 'This item has already been accepted.',
'accept_or_decline' => 'You must either accept or decline this asset.',
'cannot_delete_yourself' => 'We would feel really bad if you deleted yourself, please reconsider.',
'incorrect_user_accepted' => 'The asset you have attempted to accept was not checked out to you.',
+2
View File
@@ -564,6 +564,8 @@ return [
'assigned_assets_removed' => 'The following were removed from the selected assets because they are already checked out',
'upload_files' => 'Upload Files',
'uploaded_files' => 'Uploaded Files',
'sign_in_place' => 'Sign/Accept in place',
'sign_in_place_help' => 'Check this box if you have the user present and wish for them to accept the item and sign/accept the EULA (when applicable) right now.',
'importer' => [
'checked_out_to_fullname' => 'Checked Out to: Full Name',
'checked_out_to_first_name' => 'Checked Out to: First Name',
+16 -1
View File
@@ -98,7 +98,7 @@
</div>
@if ($accessory->requireAcceptance() || $accessory->getEula() || ($snipeSettings->webhook_endpoint!=''))
@if ($accessory->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $accessory->getEula() || ($snipeSettings->webhook_endpoint!=''))
<div class="form-group notification-callout">
<div class="col-md-8 col-md-offset-3">
<div class="callout callout-info">
@@ -121,6 +121,21 @@
@endif
</div>
</div>
<!-- Sign in place checkbox -->
@if ($accessory->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1')
<div class="form-group" id="sign_in_place_div">
<div class="col-md-7 col-md-offset-3">
<label class="form-control">
<input type="checkbox" value="1" name="sign_in_place" @checked(old('sign_in_place', session('sign_in_place', false))) aria-label="sign_in_place">
{{ trans('general.sign_in_place') }}
</label>
<p class="help-block">
{{ trans('general.sign_in_place_help') }}
</p>
</div>
</div>
@endif
</div>
@endif
<!-- Note -->
@@ -47,20 +47,32 @@
<div class="col-sm-12 col-sm-offset-1 col-md-10 col-md-offset-1">
<div class="panel box box-default">
<div class="box-header with-border">
<h2 class="box-title">
<h2 class="box-title" style="line-height: 25px;">
{{ $acceptance->checkoutable->display_name }}
@if ($acceptance->qty > 1)
<strong>×{{ $acceptance->qty }}</strong>
@endif
{!! (($acceptance->checkoutable) && ($acceptance->checkoutable->serial)) ? '<br>'.trans('general.serial_number').': '.e($acceptance->checkoutable->serial) : '' !!}
@if (($acceptance->checkoutable) && ($acceptance->checkoutable->serial))
<br>{{ trans('general.serial_number') }}: {{ $acceptance->checkoutable->serial }}
@endif
</h2>
</div>
<div class="box-body">
@if ($acceptance->checkoutable->getEula())
<div class="col-md-12" style="padding-bottom: 12px;">
<p class="text-muted" style="margin-bottom: 4px;">
<strong>{{ trans('general.assigned_date') }}:</strong> {{ $checkedOutAt }}
</p>
<p class="text-muted" style="margin-bottom: 0;">
<strong>{{ trans('general.created_by') }}
:</strong> {{ $checkedOutBy ?? trans('general.unknown_admin') }}
</p>
</div>
@if ($acceptance->checkoutable->getEula())
<div class="col-md-12" style="padding-top: 5px; padding-bottom: 15px;">
<div style="background-color: rgba(211,211,211,0.25); padding: 0px 10px 10px 10px; border: lightgrey 1px solid;">
<div style="background-color: rgba(211,211,211,0.25); padding: 10px; border: var(--box-header-bottom-border-color) 1px solid;">
{!! str_replace('<p>', '<p dir="auto">', Helper::parseEscapedMarkedown($acceptance->checkoutable->getEula())) !!}
</div>
</div>
@@ -112,11 +124,11 @@
<div class="box-footer" style="display: none;" id="showSubmit">
<div class="row">
<div class="col-md-7">
@if (auth()->user()->email!='')
@if ($acceptance->assignedTo?->email)
<div class="col-md-12" style="display: none;" id="showEmailBox">
<label class="form-control">
<input type="checkbox" value="1" name="send_copy" id="send_copy" checked="checked" aria-label="send_copy">
{{ trans('mail.send_pdf_copy') }} ({{ auth()->user()->email }})
{{ trans('mail.send_pdf_copy') }} ({{ $acceptance->assignedTo->email }})
</label>
</div>
@endif
@@ -146,7 +158,6 @@
@if ($snipeSettings->require_accept_signature=='1')
var wrapper = document.getElementById("signature-pad"),
saveButton = wrapper.querySelector("[data-action=save]"),
canvas = wrapper.querySelector("canvas"),
signaturePad;
@@ -197,7 +208,7 @@
$("#showEmailBox").show();
$("#showSubmit").show();
$("#submit-button").removeClass("btn-danger").addClass("btn-success").show();
$("#submitIcon").removeClass("fa-check").addClass("fa-check");
$("#submitIcon").removeClass("fa-times").addClass("fa-check");
$("#buttonText").text('{{ trans_choice('general.i_accept_item', $acceptance->qty ?? 1) }}');
$("#note").prop('required', false);
}
+22 -1
View File
@@ -82,7 +82,7 @@
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.select_user'), 'fieldname' => 'assigned_to', 'required'=> 'true'])
@if ($consumable->requireAcceptance() || $consumable->getEula() || ($snipeSettings->webhook_endpoint!=''))
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $consumable->getEula() || ($snipeSettings->webhook_endpoint!=''))
<div class="form-group notification-callout">
<div class="col-md-8 col-md-offset-3">
<div class="callout callout-info">
@@ -99,12 +99,33 @@
<br>
@endif
@if (($consumable->category) && ($consumable->category->checkin_email))
<i class="far fa-envelope"></i>
{{ trans('admin/categories/general.checkin_email_notification') }}
<br>
@endif
@if ($snipeSettings->webhook_endpoint!='')
<i class="fab fa-slack"></i>
{{ trans('general.webhook_msg_note') }}
@endif
</div>
</div>
<!-- Sign in place checkbox -->
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1')
<div class="form-group" id="sign_in_place_div">
<div class="col-md-7 col-md-offset-3">
<label class="form-control">
<input type="checkbox" value="1" name="sign_in_place" @checked(old('sign_in_place', session('sign_in_place', false))) aria-label="sign_in_place">
{{ trans('general.sign_in_place') }}
</label>
<p class="help-block">
{{ trans('general.sign_in_place_help') }}
</p>
</div>
</div>
@endif
</div>
@endif
+26 -6
View File
@@ -167,34 +167,54 @@
@if ($asset->requireAcceptance() || $asset->getEula() || ($snipeSettings->webhook_endpoint!=''))
<div class="row">
<div class="notification-callout">
@if ($asset->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $asset->getEula() || ($snipeSettings->webhook_endpoint!=''))
<div class="form-group notification-callout" style="display:none;">
<div class="col-md-8 col-md-offset-3">
<div class="callout callout-info">
@if ($asset->requireAcceptance())
<x-icon type="email" />
<x-icon type="email"/>
{{ trans('admin/categories/general.required_acceptance') }}
<br>
@endif
@if ($asset->getEula())
<x-icon type="email" />
<x-icon type="email"/>
{{ trans('admin/categories/general.required_eula') }}
<br>
@endif
@if (($asset->model?->category) && ($asset->model->category->checkin_email))
<x-icon type="email"/>
{{ trans('admin/categories/general.checkin_email_notification') }}
<br>
@endif
@if ($snipeSettings->webhook_endpoint!='')
<i class="fab fa-slack" aria-hidden="true"></i>
{{ trans('general.webhook_msg_note') }}
@endif
</div>
</div>
</div>
<!-- Sign in place checkbox -->
@if ($asset->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1')
<div class="form-group" id="sign_in_place_div">
<div class="col-md-7 col-md-offset-3">
<label class="form-control">
<input type="checkbox" value="1" name="sign_in_place" @checked(old('sign_in_place', session('sign_in_place', false))) aria-label="sign_in_place">
{{ trans('general.sign_in_place') }}
</label>
<p class="help-block">
{{ trans('general.sign_in_place_help') }}
</p>
</div>
</div>
@endif
</div>
@endif
</div> <!--/.box-body-->
<x-redirect_submit_options
+16 -1
View File
@@ -86,7 +86,7 @@
</div>
@if ($license->requireAcceptance() || $license->getEula() || ($snipeSettings->webhook_endpoint!=''))
@if ($license->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $license->getEula() || ($snipeSettings->webhook_endpoint!=''))
<div class="form-group notification-callout">
<div class="col-md-8 col-md-offset-3">
<div class="callout callout-info">
@@ -115,6 +115,21 @@
@endif
</div>
</div>
<!-- Sign in place checkbox -->
@if ($license->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1')
<div class="form-group" id="sign_in_place_div">
<div class="col-md-7 col-md-offset-3">
<label class="form-control">
<input type="checkbox" value="1" name="sign_in_place" @checked(old('sign_in_place', session('sign_in_place', false))) aria-label="sign_in_place">
{{ trans('general.sign_in_place') }}
</label>
<p class="help-block">
{{ trans('general.sign_in_place_help') }}
</p>
</div>
</div>
@endif
</div>
@endif
@@ -2,7 +2,7 @@
<!-- EULA text -->
<div class="form-group {{ $errors->has('eula_text') ? 'error' : '' }}">
<label for="eula_text" class="col-md-3 control-label">{{ trans('admin/categories/general.eula_text') }}</label>
<div class="col-md-7">
<div class="col-md-8">
<x-input.textarea
name="eula_text"
wire:model.live.change.live="eulaText"
@@ -20,7 +20,7 @@
<!-- Use default checkbox -->
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="col-md-8 col-md-offset-3">
@if ($defaultEulaText!='')
<label class="form-control">
<input
@@ -50,7 +50,7 @@
<!-- Require Acceptance -->
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="col-md-8 col-md-offset-3">
<label class="form-control">
<input
type="checkbox"
@@ -61,12 +61,19 @@
/>
{{ trans('admin/categories/general.require_acceptance') }}
</label>
@if ($this->isGlobalSignatureRequired)
<p class="help-block">
<x-icon type="tip"/>
{{ trans('admin/categories/general.global_signature_required_notice') }}
</p>
@endif
</div>
</div>
@if ($requireAcceptance)
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="col-md-8 col-md-offset-3">
<label class="form-control">
<input
type="checkbox"
@@ -82,7 +89,7 @@
<!-- Email on Checkin -->
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="col-md-8 col-md-offset-3">
<label class="form-control">
<input
type="checkbox"
+4 -5
View File
@@ -1,5 +1,6 @@
<?php
use App\Actions\Breadcrumbs\BuildAcceptanceBreadcrumbs;
use App\Http\Controllers\Account;
use App\Http\Controllers\ActionlogController;
use App\Http\Controllers\Auth\ForgotPasswordController;
@@ -425,13 +426,11 @@ Route::group(['prefix' => 'account', 'middleware' => ['auth']], function () {
->push(trans('general.profile'), route('account'))
->push(trans('general.accept_items'), route('account.accept')));
Route::get('accept/{id}', [Account\AcceptanceController::class, 'create'])
Route::get('accept/{acceptance}', [Account\AcceptanceController::class, 'create'])
->name('account.accept.item')
->breadcrumbs(fn (Trail $trail, $id) => $trail->parent('home')
->push(trans('general.profile'), route('account'))
->push(trans('general.accept_item'), route('account.accept.item', $id)));
->breadcrumbs(fn (Trail $trail, mixed $acceptance) => BuildAcceptanceBreadcrumbs::forAcceptance($trail, $acceptance));
Route::post('accept/{id}', [Account\AcceptanceController::class, 'store'])
Route::post('accept/{acceptance}', [Account\AcceptanceController::class, 'store'])
->name('account.store-acceptance');
Route::get(
@@ -206,4 +206,33 @@ class AccessoryAcceptanceTest extends TestCase
$this->assertEquals($originalAccessoryCheckoutCount - 3, AccessoryCheckout::count());
}
public function test_admin_can_complete_sign_in_place_accessory_acceptance_and_is_redirected_to_selected_destination()
{
$assignee = User::factory()->create();
$admin = User::factory()->admin()->create();
$accessory = Accessory::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($assignee, 'assignedTo')
->for($accessory, 'checkoutable')
->create();
$this->actingAs($admin)
->withSession([
'sign_in_place_acceptance_id' => $checkoutAcceptance->id,
'sign_in_place_item_id' => $accessory->id,
'sign_in_place_resource_type' => 'Accessories',
'redirect_option' => 'target',
'checkout_to_type' => 'user',
])
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
'note' => 'signed in person',
])
->assertRedirect(route('users.show', $assignee));
$this->assertNotNull($checkoutAcceptance->refresh()->accepted_at);
}
}
@@ -6,6 +6,7 @@ use App\Events\CheckoutAccepted;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
@@ -88,6 +89,48 @@ class AssetAcceptanceTest extends TestCase
Event::assertDispatched(CheckoutAccepted::class);
}
public function test_user_can_accept_asset_with_required_signature()
{
if (! function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension is required for signature image generation.');
}
$settings = Setting::query()->firstOrFail();
$settings->require_accept_signature = 1;
$settings->save();
Setting::$_cache = null;
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
$canvas = imagecreatetruecolor(24, 12);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefill($canvas, 0, 0, $transparent);
imagesavealpha($canvas, true);
$ink = imagecolorallocate($canvas, 25, 25, 25);
imageline($canvas, 2, 10, 21, 2, $ink);
ob_start();
imagepng($canvas);
$signaturePng = (string) ob_get_clean();
imagedestroy($canvas);
$signatureOutput = 'data:image/png;base64,'.base64_encode($signaturePng);
$this->actingAs($checkoutAcceptance->assignedTo)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
'note' => 'signed in test',
'signature_output' => $signatureOutput,
])
->assertRedirectToRoute('account.accept')
->assertSessionHas('success');
$checkoutAcceptance->refresh();
$this->assertNotNull($checkoutAcceptance->signature_filename);
$this->assertNotNull($checkoutAcceptance->stored_eula_file);
}
public function test_user_can_decline_asset()
{
Event::fake([CheckoutAccepted::class]);
@@ -160,4 +203,121 @@ class AssetAcceptanceTest extends TestCase
->exists()
);
}
public function test_admin_can_complete_sign_in_place_acceptance_and_is_redirected_to_selected_destination()
{
Event::fake([CheckoutAccepted::class]);
$assignee = User::factory()->create();
$admin = User::factory()->admin()->create();
$asset = Asset::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($assignee, 'assignedTo')
->for($asset, 'checkoutable')
->create();
$this->actingAs($admin)
->withSession([
'sign_in_place_acceptance_id' => $checkoutAcceptance->id,
'sign_in_place_item_id' => $asset->id,
'sign_in_place_resource_type' => 'Assets',
'redirect_option' => 'target',
'checkout_to_type' => 'user',
])
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
'note' => 'signed in person',
])
->assertRedirect(route('users.show', $assignee));
$this->assertNotNull($checkoutAcceptance->refresh()->accepted_at);
Event::assertDispatched(CheckoutAccepted::class);
}
public function test_stale_sign_in_place_post_on_already_accepted_item_redirects_to_intended_destination()
{
$assignee = User::factory()->create();
$admin = User::factory()->admin()->create();
$asset = Asset::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->accepted()
->for($assignee, 'assignedTo')
->for($asset, 'checkoutable')
->create();
$this->actingAs($admin)
->withSession([
'sign_in_place' => true,
'redirect_option' => 'target',
'checkout_to_type' => 'user',
])
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
])
->assertRedirect(route('users.show', $assignee));
}
public function test_stale_sign_in_place_post_with_missing_assignee_does_not_throw_route_error()
{
$admin = User::factory()->admin()->create();
$asset = Asset::factory()->create();
$assignee = User::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->accepted()
->for($assignee, 'assignedTo')
->for($asset, 'checkoutable')
->create();
CheckoutAcceptance::whereKey($checkoutAcceptance->id)->update(['assigned_to_id' => null]);
$this->actingAs($admin)
->withSession([
'sign_in_place' => true,
'redirect_option' => 'target',
'checkout_to_type' => 'user',
])
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
])
->assertRedirectToRoute('account.accept')
->assertSessionHas('error');
}
public function test_sign_in_place_acceptance_page_uses_checkout_flow_breadcrumbs()
{
$assignee = User::factory()->create();
$admin = User::factory()->admin()->create();
$asset = Asset::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($assignee, 'assignedTo')
->for($asset, 'checkoutable')
->create();
$response = $this->actingAs($admin)
->withSession([
'sign_in_place_acceptance_id' => $checkoutAcceptance->id,
'sign_in_place_item_id' => $asset->id,
'sign_in_place_resource_type' => 'Assets',
])
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk()
->assertSeeInOrder([
trans('general.assets'),
$asset->display_name,
trans('general.checkout'),
sprintf('%s for %s', trans('general.sign_in_place'), $assignee->display_name),
], false)
->assertSee(route('hardware.index'), false)
->assertSee(route('hardware.show', $asset), false)
->assertDontSee(route('users.show', $assignee), false)
->assertDontSee(route('hardware.checkout.create', $asset), false);
}
}
@@ -6,6 +6,7 @@ use App\Mail\CheckoutAccessoryMail;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Location;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
@@ -173,7 +174,7 @@ class AccessoryCheckoutTest extends TestCase
{
Mail::fake();
$accessory = Accessory::factory()->requiringAcceptance()->create();
$accessory = Accessory::factory()->requiringAcceptance()->create(['qty' => 5]);
$user = User::factory()->create();
$this->actingAs(User::factory()->checkoutAccessories()->create())
@@ -267,4 +268,50 @@ class AccessoryCheckoutTest extends TestCase
->assertStatus(302)
->assertRedirect(route('users.show', $user));
}
public function test_accessory_checkout_page_post_redirects_to_signature_page_when_sign_in_place_is_checked()
{
$targetUser = User::factory()->create();
$accessory = Accessory::factory()->requiringAcceptance()->create(['qty' => 5]);
$response = $this->actingAs(User::factory()->admin()->create())
->from(route('accessories.checkout.show', $accessory))
->post(route('accessories.checkout.store', $accessory), [
'assigned_user' => $targetUser->id,
'checkout_to_type' => 'user',
'redirect_option' => 'index',
'checkout_qty' => 2,
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', Accessory::class)
->where('checkoutable_id', $accessory->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$this->assertEquals(2, $acceptance->qty);
$response->assertStatus(302)
->assertRedirect(route('account.accept.item', $acceptance));
}
public function test_accessory_checkout_stores_sign_in_place_preference_in_session()
{
$targetUser = User::factory()->create();
$accessory = Accessory::factory()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('accessories.checkout.store', $accessory), [
'assigned_user' => $targetUser->id,
'checkout_to_type' => 'user',
'redirect_option' => 'index',
'sign_in_place' => 1,
]);
$response->assertSessionHas('sign_in_place', true);
}
}
@@ -4,6 +4,7 @@ namespace Tests\Feature\Checkouts\Ui;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\LicenseSeat;
use App\Models\Location;
@@ -348,4 +349,48 @@ class AssetCheckoutTest extends TestCase
->assertStatus(302)
->assertRedirect(route('locations.show', ['location' => $target]));
}
public function test_asset_checkout_page_post_redirects_to_signature_page_when_sign_in_place_is_checked()
{
$targetUser = User::factory()->create();
$asset = Asset::factory()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->from(route('hardware.checkout.create', $asset))
->post(route('hardware.checkout.store', $asset), [
'checkout_to_type' => 'user',
'assigned_user' => $targetUser->id,
'redirect_option' => 'index',
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$response->assertStatus(302)
->assertRedirect(route('account.accept.item', $acceptance));
}
public function test_asset_checkout_stores_sign_in_place_preference_in_session()
{
$targetUser = User::factory()->create();
$asset = Asset::factory()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('hardware.checkout.store', $asset), [
'checkout_to_type' => 'user',
'assigned_user' => $targetUser->id,
'redirect_option' => 'index',
'sign_in_place' => 1,
]);
$response->assertSessionHas('sign_in_place', true);
}
}
@@ -4,6 +4,7 @@ namespace Tests\Feature\Checkouts\Ui;
use App\Mail\CheckoutConsumableMail;
use App\Models\Actionlog;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
@@ -172,4 +173,49 @@ class ConsumableCheckoutTest extends TestCase
'created_by' => $admin->id,
]);
}
public function test_consumable_checkout_page_post_redirects_to_signature_page_when_sign_in_place_is_checked()
{
$targetUser = User::factory()->create();
$consumable = Consumable::factory()->requiringAcceptance()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->from(route('consumables.checkout.show', $consumable))
->post(route('consumables.checkout.store', $consumable), [
'assigned_to' => $targetUser->id,
'redirect_option' => 'index',
'checkout_qty' => 2,
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', Consumable::class)
->where('checkoutable_id', $consumable->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$this->assertEquals(2, $acceptance->qty);
$response->assertStatus(302)
->assertRedirect(route('account.accept.item', $acceptance));
}
public function test_consumable_checkout_stores_sign_in_place_preference_in_session()
{
$targetUser = User::factory()->create();
$consumable = Consumable::factory()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('consumables.checkout.store', $consumable), [
'assigned_to' => $targetUser->id,
'redirect_option' => 'index',
'checkout_qty' => 1,
'sign_in_place' => 1,
]);
$response->assertSessionHas('sign_in_place', true);
}
}
@@ -3,6 +3,7 @@
namespace Tests\Feature\Checkouts\Ui;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
@@ -124,4 +125,45 @@ class LicenseCheckoutTest extends TestCase
->assertStatus(302)
->assertRedirect(route('hardware.show', $asset));
}
public function test_license_checkout_page_post_redirects_to_signature_page_when_sign_in_place_is_checked()
{
$targetUser = User::factory()->create();
$seat = LicenseSeat::factory()->requiringAcceptance()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->from(route('licenses.checkout', $seat->license))
->post(route('licenses.checkout', $seat->license), [
'assigned_to' => $targetUser->id,
'redirect_option' => 'index',
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', LicenseSeat::class)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$response->assertStatus(302)
->assertRedirect(route('account.accept.item', $acceptance));
}
public function test_license_checkout_stores_sign_in_place_preference_in_session()
{
$targetUser = User::factory()->create();
$seat = LicenseSeat::factory()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('licenses.checkout', $seat->license), [
'assigned_to' => $targetUser->id,
'redirect_option' => 'index',
'sign_in_place' => 1,
]);
$response->assertSessionHas('sign_in_place', true);
}
}
@@ -0,0 +1,141 @@
<?php
namespace Tests\Feature\Notifications\Email;
use App\Mail\CheckoutAccessoryMail;
use App\Mail\CheckoutAssetMail;
use App\Mail\CheckoutConsumableMail;
use App\Mail\CheckoutLicenseMail;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\Group;
use Tests\TestCase;
#[Group('notifications')]
class SignInPlaceCheckoutEmailSuppressionTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Mail::fake();
}
public function test_asset_checkout_does_not_send_initial_acceptance_email_when_sign_in_place_is_selected(): void
{
$targetUser = User::factory()->create();
$category = Category::factory()
->forAssets()
->doesNotRequireAcceptance()
->doesNotSendCheckinEmail()
->hasLocalEula()
->create();
$asset = Asset::factory()
->for(AssetModel::factory()->for($category, 'category'), 'model')
->create();
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('hardware.checkout.store', $asset), [
'checkout_to_type' => 'user',
'assigned_user' => $targetUser->id,
'redirect_option' => 'index',
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$response->assertRedirect(route('account.accept.item', $acceptance));
Mail::assertNotSent(CheckoutAssetMail::class);
}
public function test_consumable_checkout_does_not_send_initial_acceptance_email_when_sign_in_place_is_selected(): void
{
$targetUser = User::factory()->create();
$consumable = Consumable::factory()->requiringAcceptance()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('consumables.checkout.store', $consumable), [
'assigned_to' => $targetUser->id,
'redirect_option' => 'index',
'checkout_qty' => 2,
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', Consumable::class)
->where('checkoutable_id', $consumable->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$response->assertRedirect(route('account.accept.item', $acceptance));
Mail::assertNotSent(CheckoutConsumableMail::class);
}
public function test_license_checkout_does_not_send_initial_acceptance_email_when_sign_in_place_is_selected(): void
{
$targetUser = User::factory()->create();
$seat = LicenseSeat::factory()->requiringAcceptance()->create();
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('licenses.checkout', $seat->license), [
'assigned_to' => $targetUser->id,
'redirect_option' => 'index',
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', LicenseSeat::class)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$response->assertRedirect(route('account.accept.item', $acceptance));
Mail::assertNotSent(CheckoutLicenseMail::class);
}
public function test_accessory_checkout_does_not_send_initial_acceptance_email_when_sign_in_place_is_selected(): void
{
$targetUser = User::factory()->create();
$accessory = Accessory::factory()->requiringAcceptance()->create(['qty' => 5]);
$response = $this->actingAs(User::factory()->admin()->create())
->post(route('accessories.checkout.store', $accessory), [
'assigned_user' => $targetUser->id,
'checkout_to_type' => 'user',
'redirect_option' => 'index',
'checkout_qty' => 2,
'sign_in_place' => 1,
]);
$acceptance = CheckoutAcceptance::query()
->where('checkoutable_type', Accessory::class)
->where('checkoutable_id', $accessory->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
$this->assertNotNull($acceptance);
$response->assertRedirect(route('account.accept.item', $acceptance));
Mail::assertNotSent(CheckoutAccessoryMail::class);
}
}