Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2026-06-09 12:47:23 +01:00
18 changed files with 388 additions and 28 deletions
@@ -67,7 +67,12 @@ class AccessoryCheckoutController extends Controller
$target = $this->determineCheckoutTarget();
session()->put(['checkout_to_type' => $target]);
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $target->companies()->where('companies.id', $accessory->company_id)->exists())) {
if (
Setting::getSettings()->full_multiple_companies_support == '1'
&& $accessory->company_id
&& $target instanceof User
&& ! $target->canReceiveFromCompany($accessory->company_id)
) {
return redirect()->back()->with('error', trans('general.error_user_company'));
}
@@ -97,7 +97,11 @@ class ConsumableCheckoutController extends Controller
return redirect()->route('consumables.checkout.show', $consumable)->with('error', trans('admin/consumables/message.checkout.user_does_not_exist'))->withInput();
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $user->companies()->where('companies.id', $consumable->company_id)->exists())) {
if (
Setting::getSettings()->full_multiple_companies_support == '1'
&& $consumable->company_id
&& ! $user->canReceiveFromCompany($consumable->company_id)
) {
return redirect()->back()->with('error', trans('general.error_user_company'));
}
@@ -104,7 +104,7 @@ class LicenseCheckoutController extends Controller
}
} elseif ($request->filled('assigned_to')) {
$fmcsTarget = User::find($request->input('assigned_to'));
if ($fmcsTarget && ! $fmcsTarget->companies()->where('companies.id', $license->company_id)->exists()) {
if ($fmcsTarget && $license->company_id && ! $fmcsTarget->canReceiveFromCompany($license->company_id)) {
return redirect()->route('licenses.index')->with('error', trans('general.error_user_company'));
}
}
+13 -2
View File
@@ -10,11 +10,14 @@ use App\Http\Requests\DeleteUserRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\SaveUserRequest;
use App\Mail\UnacceptedAssetReminderMail;
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\Group;
use App\Models\License;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CurrentInventory;
@@ -702,9 +705,17 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::withInventoryRelations($id)->first();
$actor = auth()->user();
$canViewLicenses = $actor->can('view', License::class);
$canViewAccessories = $actor->can('view', Accessory::class);
$canViewConsumables = $actor->can('view', Consumable::class);
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
$user = User::withInventoryRelations($id, $canViewLicenses, $canViewAccessories, $canViewConsumables)->first();
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count()
+ $user?->assets?->flatMap->components->count()
+ ($canViewLicenses ? $user?->assets?->flatMap->licenses->count() : 0)
+ ($canViewAccessories ? $user?->assets?->flatMap->assignedAccessories->count() : 0);
if ($user) {
$this->authorize('view', $user);
+38 -23
View File
@@ -1522,28 +1522,38 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
}
public function scopeWithInventoryRelations($query, int $id)
public function scopeWithInventoryRelations($query, int $id, bool $withLicenses = true, bool $withAccessories = true, bool $withConsumables = true)
{
return $query->where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.assignedAssets.assignedTo',
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.components.category',
$with = [
'assets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.assignedAssets.assignedTo',
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.components.category',
];
if ($withLicenses) {
$with = array_merge($with, [
'assets.licenses',
'assets.licenses.category',
'directLicenses.category',
'licenses.category',
]);
}
if ($withAccessories) {
$with = array_merge($with, [
'assets.assignedAccessories',
'assets.assignedAccessories.accessory.category',
'accessories.log' => fn ($query) => $query->withTrashed()
@@ -1552,16 +1562,21 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
]);
}
if ($withConsumables) {
$with = array_merge($with, [
'consumables.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'directLicenses.category',
'licenses.category',
])
->withTrashed();
]);
}
return $query->where('id', $id)->with($with)->withTrashed();
}
/**
+4
View File
@@ -580,6 +580,10 @@ return [
'system_default' => 'Use System Settings',
'system_default_help' => 'This will reset your light/dark mode preferences to use the defaults set in your computer operating system preferences.',
'theme' => 'Theme',
'fmcs_select_note' => 'Full Multiple Company Support is enabled. Selections from other companies may not appear in this list.',
'fmcs_location_select_note' => 'Full Multiple Company Support with location scoping is enabled. Only locations belonging to your company will appear in this list.',
'fmcs_company_select_note' => 'Full Multiple Company Support is enabled. You can only assign companies you belong to.',
'fmcs_company_select_superadmin_note' => 'Full Multiple Company Support is enabled. The company assigned here may affect visibility for non-superadmin users.',
'error_user_company' => 'Checkout target company and asset company do not match',
'error_user_company_multiple' => 'One or more of the checkout target company and asset company do not match',
'error_user_company_accept_view' => 'An Asset assigned to you belongs to a different company so you can\'t accept nor deny it, please check with your manager',
@@ -48,5 +48,18 @@
</div>
@endunless
@if ($snipeSettings->full_multiple_companies_support == '1')
@cannot('superadmin')
<div class="col-md-6 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_company_select_note') }}</p>
</div>
@endcannot
@can('superadmin')
<div class="col-md-6 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_company_select_superadmin_note') }}</p>
</div>
@endcan
@endif
{!! $errors->first($name, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
</div>
@@ -15,6 +15,14 @@
@endif
</select>
</div>
@if ($snipeSettings->full_multiple_companies_support == '1')
@cannot('superadmin')
<div class="col-md-7 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_select_note') }}</p>
</div>
@endcannot
@endif
{!! $errors->first($fieldname, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg"><i class="fas fa-times"></i> :message</span></div>') !!}
</div>
@@ -37,6 +37,14 @@
@endif
</select>
</div>
@if ($snipeSettings->full_multiple_companies_support == '1')
@cannot('superadmin')
<div class="col-md-7 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_select_note') }}</p>
</div>
@endcannot
@endif
{!! $errors->first($fieldname, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
</div>
@@ -23,5 +23,18 @@
@endif
</select>
</div>
@if ($snipeSettings->full_multiple_companies_support == '1')
@cannot('superadmin')
<div class="col-md-6 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_company_select_note') }}</p>
</div>
@endcannot
@can('superadmin')
<div class="col-md-6 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_company_select_superadmin_note') }}</p>
</div>
@endcan
@endif
{!! $errors->first($fieldname, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
</div>
@@ -15,6 +15,14 @@
@endif
</select>
</div>
@if ($snipeSettings->full_multiple_companies_support == '1')
@cannot('superadmin')
<div class="col-md-7 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_select_note') }}</p>
</div>
@endcannot
@endif
{!! $errors->first($fieldname, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg"><i class="fas fa-times"></i> :message</span></div>') !!}
</div>
@@ -15,6 +15,14 @@
@endif
</select>
</div>
@if ($snipeSettings->full_multiple_companies_support == '1')
@cannot('superadmin')
<div class="col-md-7 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_select_note') }}</p>
</div>
@endcannot
@endif
{!! $errors->first($fieldname, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg"><i class="fas fa-times"></i> :message</span></div>') !!}
</div>
@@ -16,6 +16,14 @@
{!! $errors->first('location_id', '<div class="col-md-8 col-md-offset-3"><span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
@if ($snipeSettings->full_multiple_companies_support == '1' && $snipeSettings->scope_locations_fmcs == '1')
@cannot('superadmin')
<div class="col-md-8 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_location_select_note') }}</p>
</div>
@endcannot
@endif
</div>
@@ -29,6 +29,14 @@
{!! $errors->first($fieldname, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
@if ($snipeSettings->full_multiple_companies_support == '1' && $snipeSettings->scope_locations_fmcs == '1')
@cannot('superadmin')
<div class="col-md-7 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_location_select_note') }}</p>
</div>
@endcannot
@endif
@if (isset($help_text))
<div class="col-md-7 col-sm-11 col-md-offset-3">
<p class="help-block">{{ $help_text }}</p>
@@ -22,6 +22,14 @@
@endcan
</div>
@if ($snipeSettings->full_multiple_companies_support == '1')
@cannot('superadmin')
<div class="col-md-7 col-md-offset-3">
<p class="help-block"><x-icon type="tip" /> {{ trans('general.fmcs_select_note') }}</p>
</div>
@endcannot
@endif
{!! $errors->first($fieldname, '<div class="col-md-8 col-md-offset-3"><span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
</div>
+10
View File
@@ -176,6 +176,7 @@
</table>
@endif
@can('view', \App\Models\License::class)
@if ($show_user->licenses->count() > 0)
<div id="licenses-toolbar">
<h4>{{ trans_choice('general.countable.licenses', $show_user->licenses->count(), ['count' => $show_user->licenses->count()]) }}</h4>
@@ -236,8 +237,10 @@
@endforeach
</table>
@endif
@endcan
@can('view', \App\Models\Accessory::class)
@if ($show_user->accessories->count() > 0)
<div id="accessories-toolbar">
<h4>{{ trans_choice('general.countable.accessories', $show_user->accessories->count(), ['count' => $show_user->accessories->count()]) }}</h4>
@@ -301,7 +304,9 @@
@endforeach
</table>
@endif
@endcan
@can('view', \App\Models\Consumable::class)
@if ($show_user->consumables->count() > 0)
<div id="consumables-toolbar">
<h4>{{ trans_choice('general.countable.consumables', $show_user->consumables->count(), ['count' => $show_user->consumables->count()]) }}</h4>
@@ -365,6 +370,7 @@
@endforeach
</table>
@endif
@endcan
@if(($indirectItemsCount ?? 0) > 0 && $settings->show_assigned_assets)
<div id="indirect-assignments-toolbar">
<h4>{{ $indirectItemsCount.' '.trans('mail.assigned_to_assets') }}</h4>
@@ -409,6 +415,7 @@
$indirectAssignmentsCounter++
@endphp
@endforeach
@can('view', \App\Models\License::class)
@foreach ($asset->licenses as $indirectLicense)
@if($indirectLicense)
<tr>
@@ -423,6 +430,7 @@
$indirectAssignmentsCounter ++
@endphp
@endforeach
@endcan
@foreach ($asset->components as $component)
@if($component)
<tr>
@@ -437,6 +445,7 @@
$indirectAssignmentsCounter ++
@endphp
@endforeach
@can('view', \App\Models\Accessory::class)
@foreach ($asset->assignedAccessories as $indirectAccessory)
@if($indirectAccessory)
<tr>
@@ -451,6 +460,7 @@
$indirectAssignmentsCounter ++
@endphp
@endforeach
@endcan
@endforeach
</table>
@endif
@@ -0,0 +1,141 @@
<?php
namespace Tests\Feature\Accessories\Ui;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Location;
use App\Models\User;
use Tests\TestCase;
class CheckoutAccessoryTest extends TestCase
{
public function test_checkout_to_user_in_same_company_succeeds_with_fmcs_enabled()
{
[$companyA] = Company::factory()->count(1)->create();
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
$user = User::factory()->for($companyA)->create();
$user->companies()->sync([$companyA->id]);
$this->settings->enableMultipleFullCompanySupport();
$actor = User::factory()->superuser()->create();
$this->actingAs($actor)
->post(route('accessories.checkout.store', $accessory), [
'checkout_to_type' => 'user',
'assigned_user' => $user->id,
'checkout_qty' => 1,
'redirect_option' => 'index',
])
->assertRedirect();
$this->assertDatabaseHas('accessories_checkout', [
'accessory_id' => $accessory->id,
'assigned_to' => $user->id,
]);
}
public function test_checkout_to_user_in_different_company_is_blocked_with_fmcs_enabled()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
$user = User::factory()->for($companyB)->create();
$user->companies()->sync([$companyB->id]);
$this->settings->enableMultipleFullCompanySupport();
$actor = User::factory()->superuser()->create();
$this->actingAs($actor)
->post(route('accessories.checkout.store', $accessory), [
'checkout_to_type' => 'user',
'assigned_user' => $user->id,
'checkout_qty' => 1,
'redirect_option' => 'index',
])
->assertRedirect();
$this->assertDatabaseMissing('accessories_checkout', [
'accessory_id' => $accessory->id,
'assigned_to' => $user->id,
]);
}
public function test_checkout_to_user_succeeds_when_accessory_has_no_company_with_fmcs_enabled()
{
$accessory = Accessory::factory()->create(['qty' => 5, 'company_id' => null]);
[$companyA] = Company::factory()->count(1)->create();
$user = User::factory()->for($companyA)->create();
$user->companies()->sync([$companyA->id]);
$this->settings->enableMultipleFullCompanySupport();
$actor = User::factory()->superuser()->create();
$this->actingAs($actor)
->post(route('accessories.checkout.store', $accessory), [
'checkout_to_type' => 'user',
'assigned_user' => $user->id,
'checkout_qty' => 1,
'redirect_option' => 'index',
])
->assertRedirect();
$this->assertDatabaseHas('accessories_checkout', [
'accessory_id' => $accessory->id,
'assigned_to' => $user->id,
]);
}
public function test_checkout_to_asset_does_not_throw_when_fmcs_enabled()
{
[$companyA] = Company::factory()->count(1)->create();
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
$asset = Asset::factory()->for($companyA)->create();
$this->settings->enableMultipleFullCompanySupport();
$actor = User::factory()->superuser()->create();
$this->actingAs($actor)
->post(route('accessories.checkout.store', $accessory), [
'checkout_to_type' => 'asset',
'assigned_asset' => $asset->id,
'checkout_qty' => 1,
'redirect_option' => 'index',
])
->assertRedirect();
$this->assertDatabaseHas('accessories_checkout', [
'accessory_id' => $accessory->id,
'assigned_to' => $asset->id,
]);
}
public function test_checkout_to_location_does_not_throw_when_fmcs_enabled()
{
[$companyA] = Company::factory()->count(1)->create();
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
$location = Location::factory()->create();
$this->settings->enableMultipleFullCompanySupport();
$actor = User::factory()->superuser()->create();
$this->actingAs($actor)
->post(route('accessories.checkout.store', $accessory), [
'checkout_to_type' => 'location',
'assigned_location' => $location->id,
'checkout_qty' => 1,
'redirect_option' => 'index',
])
->assertRedirect();
$this->assertDatabaseHas('accessories_checkout', [
'accessory_id' => $accessory->id,
'assigned_to' => $location->id,
]);
}
}
@@ -2,7 +2,11 @@
namespace Tests\Feature\Users\Ui;
use App\Models\Accessory;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tests\TestCase;
@@ -51,4 +55,88 @@ class PrintUserInventoryTest extends TestCase
])
->assertOk();
}
public function test_user_without_licenses_view_cannot_see_assigned_licenses_in_print()
{
$subject = User::factory()->create();
$license = License::factory()->create(['name' => 'Unique License XYZ123']);
LicenseSeat::factory()->for($license)->assignedToUser($subject)->create();
$actor = User::factory()->viewUsers()->create();
$this->actingAs($actor)
->get(route('users.print', $subject))
->assertOk()
->assertDontSee('Unique License XYZ123');
}
public function test_user_with_licenses_view_can_see_assigned_licenses_in_print()
{
$subject = User::factory()->create();
$license = License::factory()->create(['name' => 'Unique License XYZ123']);
LicenseSeat::factory()->for($license)->assignedToUser($subject)->create();
$actor = User::factory()->viewUsers()->viewLicenses()->create();
$this->actingAs($actor)
->get(route('users.print', $subject))
->assertOk()
->assertSee('Unique License XYZ123');
}
public function test_user_without_accessories_view_cannot_see_assigned_accessories_in_print()
{
$subject = User::factory()->create();
$accessory = Accessory::factory()->create(['name' => 'Unique Accessory ABC789']);
$accessory->checkouts()->create(['assigned_to' => $subject->id, 'assigned_type' => User::class]);
$actor = User::factory()->viewUsers()->create();
$this->actingAs($actor)
->get(route('users.print', $subject))
->assertOk()
->assertDontSee('Unique Accessory ABC789');
}
public function test_user_with_accessories_view_can_see_assigned_accessories_in_print()
{
$subject = User::factory()->create();
$accessory = Accessory::factory()->create(['name' => 'Unique Accessory ABC789']);
$accessory->checkouts()->create(['assigned_to' => $subject->id, 'assigned_type' => User::class]);
$actor = User::factory()->viewUsers()->viewAccessories()->create();
$this->actingAs($actor)
->get(route('users.print', $subject))
->assertOk()
->assertSee('Unique Accessory ABC789');
}
public function test_user_without_consumables_view_cannot_see_assigned_consumables_in_print()
{
$subject = User::factory()->create();
$consumable = Consumable::factory()->create(['name' => 'Unique Consumable DEF456']);
$subject->consumables()->attach($consumable->id, ['created_by' => $subject->id]);
$actor = User::factory()->viewUsers()->create();
$this->actingAs($actor)
->get(route('users.print', $subject))
->assertOk()
->assertDontSee('Unique Consumable DEF456');
}
public function test_user_with_consumables_view_can_see_assigned_consumables_in_print()
{
$subject = User::factory()->create();
$consumable = Consumable::factory()->create(['name' => 'Unique Consumable DEF456']);
$subject->consumables()->attach($consumable->id, ['created_by' => $subject->id]);
$actor = User::factory()->viewUsers()->viewConsumables()->create();
$this->actingAs($actor)
->get(route('users.print', $subject))
->assertOk()
->assertSee('Unique Consumable DEF456');
}
}