Compare commits

..

41 Commits

Author SHA1 Message Date
snipe c02a6c105a Bumped version 2026-05-26 23:24:04 +01:00
snipe cfa8069953 Merge remote-tracking branch 'origin/develop' 2026-05-25 13:35:12 +01:00
snipe b3be2baf40 Merge remote-tracking branch 'origin/develop' 2026-05-23 01:54:06 +01:00
snipe 069912d051 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-22 13:06:41 +01:00
snipe 86245ad4ae Merge remote-tracking branch 'origin/develop' 2026-05-22 12:44:37 +01:00
snipe c8bafdad79 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-05-22 12:28:56 +01:00
snipe c94fce2367 Merge remote-tracking branch 'origin/develop' 2026-05-22 12:00:57 +01:00
snipe 653b1327cb Merge remote-tracking branch 'origin/develop' 2026-05-22 11:56:32 +01:00
snipe 849b217300 Merge remote-tracking branch 'origin/develop' 2026-05-22 10:44:13 +01:00
snipe 371f096e54 Merge remote-tracking branch 'origin/develop' 2026-05-22 09:31:44 +01:00
snipe 72a11113e7 Merge remote-tracking branch 'origin/develop' 2026-05-21 20:20:17 +01:00
snipe b0635f24db Merge remote-tracking branch 'origin/develop' 2026-05-21 16:12:50 +01:00
snipe 96088c416e Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-21 15:26:47 +01:00
snipe c8f3e833e5 Prod assets 2026-05-21 15:26:15 +01:00
snipe 5307a44fab Merge remote-tracking branch 'origin/develop' 2026-05-21 15:06:36 +01:00
snipe 2d6eb5d80a Merge remote-tracking branch 'origin/develop' 2026-05-20 18:08:38 +01:00
snipe 90e2c105cd Merge remote-tracking branch 'origin/develop' 2026-05-20 18:05:03 +01:00
snipe 875b0bbdec Merge remote-tracking branch 'origin/develop' 2026-05-19 08:38:14 +01:00
snipe be1f1bd1c5 Merge remote-tracking branch 'origin/develop' 2026-05-19 08:09:13 +01:00
snipe c9be696c84 Merge remote-tracking branch 'origin/develop' 2026-05-18 20:14:54 +01:00
snipe 187f160b21 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:31:34 +01:00
snipe 8908b67b3d Merge remote-tracking branch 'origin/develop' 2026-05-18 16:26:27 +01:00
snipe 4373f761c7 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:18:04 +01:00
snipe 8e9bd5dbb1 Merge remote-tracking branch 'origin/develop' 2026-05-18 13:54:48 +01:00
snipe 751541a54d Merge remote-tracking branch 'origin/develop' 2026-05-18 13:12:22 +01:00
snipe 3972799e56 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:47:05 +01:00
snipe db2afd0dc7 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:33:42 +01:00
snipe 460daf71b6 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:04:12 +01:00
snipe 3074bae47c Merge remote-tracking branch 'origin/develop' 2026-05-18 11:56:26 +01:00
snipe 0f80950a91 Merge remote-tracking branch 'origin/develop' 2026-05-15 01:56:34 +01:00
snipe 2620b60048 Merge remote-tracking branch 'origin/develop' 2026-05-15 00:43:51 +01:00
snipe 81b1cdc6e9 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:58:04 +01:00
snipe 0304933c53 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:46:42 +01:00
snipe f0d84f5350 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:34:42 +01:00
snipe 1ad562f8b9 Merge remote-tracking branch 'origin/develop' 2026-05-14 12:38:37 +01:00
snipe a5cea247f1 Merge remote-tracking branch 'origin/develop' 2026-05-14 11:35:54 +01:00
snipe 571bc39495 Merge remote-tracking branch 'origin/develop' 2026-05-14 10:40:52 +01:00
snipe 8ea78fae21 Merge remote-tracking branch 'origin/develop' 2026-05-13 21:59:26 +01:00
snipe ed6b3c04ab Merge remote-tracking branch 'origin/develop' 2026-05-13 12:18:25 +01:00
snipe a4ca0a592f Merge remote-tracking branch 'origin/develop' 2026-05-13 10:20:00 +01:00
snipe 90c8689596 Prod assets 2026-05-12 19:43:34 +01:00
94 changed files with 295 additions and 55710 deletions
-9
View File
@@ -4271,15 +4271,6 @@
"contributions": [
"code"
]
},
{
"login": "CybotTM",
"name": "Sebastian Mendel",
"avatar_url": "https://avatars.githubusercontent.com/u/326348?v=4",
"profile": "https://github.com/CybotTM",
"contributions": [
"code"
]
}
]
}
+1 -1
View File
@@ -113,7 +113,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -120,7 +120,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -72,7 +72,7 @@ CORS_ALLOWED_ORIGINS="*"
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
# --------------------------------------------
# OPTIONAL: LOGIN THROTTLING
+1 -2
View File
@@ -142,7 +142,7 @@ ENABLE_HSTS=false
# OPTIONAL: CACHE SETTINGS
# --------------------------------------------
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
@@ -210,7 +210,6 @@ LOGIN_AUTOCOMPLETE=false
RESET_PASSWORD_LINK_EXPIRES=15
PASSWORD_CONFIRM_TIMEOUT=10800
PASSWORD_RESET_MAX_ATTEMPTS_PER_MIN=50
TWO_FACTOR_MAX_ATTEMPTS_PER_MIN=5
INVITE_PASSWORD_LINK_EXPIRES=1500
# --------------------------------------------
+1 -1
View File
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") | [<img src="https://avatars.githubusercontent.com/u/326348?v=4" width="110px;"/><br /><sub>Sebastian Mendel</sub>](https://github.com/CybotTM)<br />[💻](https://github.com/snipe/snipe-it/commits?author=CybotTM "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
-1
View File
@@ -56,7 +56,6 @@ COPY --from=mlocati/php-extension-installer:2.1.15 /usr/bin/install-php-extensio
RUN set -eux; \
install-php-extensions \
bcmath \
exif \
gd \
ldap \
mysqli \
-1
View File
@@ -98,7 +98,6 @@ Since the release of the JSON REST API, several third-party developers have been
- [InQRy (archived)](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft)
- [Marksman (archived)](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT
- [Python Module (archived)](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer)
[IT-Tools](https://github.com/chrisnox/Snipeit-it-tools) by @chrisnox - Browser bookmarklets for PDF handover/return protocols, digital signatures, label printing (Zebra ZD410), AirWatch MDM sync and Lansweeper CSV import.
We also have a handful of [Google Apps scripts](https://github.com/grokability/google-apps-scripts-for-snipe-it) to help with various tasks.
@@ -234,10 +234,6 @@ class AccessoriesController extends Controller
$total = $accessory_checkouts->count();
$accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get();
$accessory_checkouts->loadMorph('assignedTo', [
User::class => ['companies'],
]);
return (new AccessoriesTransformer)->transformCheckedoutAccessory($accessory_checkouts, $total);
}
@@ -307,7 +303,7 @@ class AccessoriesController extends Controller
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
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->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
@@ -603,11 +603,8 @@ class AssetsController extends Controller
])->with('model', 'status', 'assignedTo')
->NotArchived();
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$assets->whereIn('assets.company_id', $companyIds);
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$assets->where('assets.company_id', $request->input('companyId'));
}
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
@@ -315,7 +315,7 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
}
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->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
@@ -27,7 +27,7 @@ class LicenseSeatsController extends Controller
if ($license = License::find($licenseId)) {
$this->authorize('view', $license);
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.companies', 'asset.company')
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.company', 'asset.company')
->where('license_seats.license_id', $licenseId);
if ($request->input('status') == 'available') {
@@ -132,110 +132,91 @@ class LicenseSeatsController extends Controller
$this->authorize('checkout', License::class);
$errorResponse = null;
$updatedSeat = null;
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])->find($seatId);
// Fetch the seat with a pessimistic lock inside a transaction so concurrent requests
// on the same seat serialise rather than racing to overwrite each other's assignment.
DB::transaction(function () use ($request, $licenseId, $seatId, $validated, &$errorResponse, &$updatedSeat): void {
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])
->lockForUpdate()
->find($seatId);
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
}
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
return;
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetUser->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
return;
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
if (! $targetAsset) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $targetUser->companies()->where('companies.id', $license->company_id)->exists())) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
if (! $targetAsset) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
// attempt to update the license seat
$licenseSeat->fill($validated);
return;
}
// check if this update is a checkin operation
// 1. are relevant fields touched at all?
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
if (! $anythingTouched) {
return response()->json(
Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))
);
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
}
return;
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
$licenseSeat->fill($validated);
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
if (! $anythingTouched) {
$updatedSeat = $licenseSeat;
return;
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
// Are the assignment fields cleared? If yes, this is a checkin operation.
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
// The logging functions expect only one "target"; assets take precedence over users.
$target = null;
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
// Both fields are null but one was provided — the related model is purged or bad data.
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
// 2. are they cleared? if yes then this is a checkin operation
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
$target = null;
// the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
// if both asset_id and assigned_to are null then we are "checking-in"
// a related model that does not exist (possible purged or bad data).
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
}
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
return false;
}
if ($assignmentTouched) {
@@ -244,29 +225,25 @@ class LicenseSeatsController extends Controller
$licenseSeat->unreassignable_seat = true;
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
return false;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
// in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation.
$licenseSeat->logCheckout($request->input('notes'), $target);
}
}
$updatedSeat = $licenseSeat;
return true;
});
if ($errorResponse) {
return $errorResponse;
if ($updated) {
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
}
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', $updatedSeat, trans('admin/licenses/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
return Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors());
}
}
@@ -2,21 +2,15 @@
namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\LicenseSeatsTransformer;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -267,167 +261,6 @@ class LicensesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.assoc_users')));
}
/**
* Checkout a license seat to a user or asset.
*
* Accepts an optional `seat_id`; if omitted the next available free seat is used.
* `target_type` must be "user" or "asset". Supply `assigned_to` for users or
* `asset_id` for assets.
*
* This will eventually use the same form request the UI uses, but we need to update the field names first.
*
* @param int $licenseId
*/
public function checkout(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkout', $license);
$validated = $this->validate($request, [
'seat_id' => 'sometimes|integer|nullable',
'target_type' => 'required|in:user,asset',
'assigned_to' => 'required_if:target_type,user|integer|nullable',
'asset_id' => 'required_if:target_type,asset|integer|nullable',
'notes' => 'sometimes|string|nullable',
]);
if ($license->isInactive()) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.license_is_inactive')));
}
$errorResponse = null;
$updatedSeat = null;
$target = null;
DB::transaction(function () use ($license, $validated, &$errorResponse, &$updatedSeat, &$target): void {
$seatId = $validated['seat_id'] ?? null;
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->where('license_id', $license->id)->lockForUpdate()->first()
: $license->freeSeat(lock: true);
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.not_enough_seats')));
return;
}
if ($licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
if ($validated['target_type'] === 'user') {
$target = User::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['assigned_to'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.user_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && ! $target->companies()->where('companies.id', $license->company_id)->exists()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->assigned_to = $target->id;
$licenseSeat->asset_id = null;
} else {
$target = Asset::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['asset_id'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.asset_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && $license->company_id && $license->company_id !== $target->company_id) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->asset_id = $target->id;
$licenseSeat->assigned_to = null;
if ($target->checkedOutToUser()) {
$licenseSeat->assigned_to = $target->assigned_to;
}
}
$licenseSeat->notes = $validated['notes'] ?? null;
$licenseSeat->created_by = auth()->id();
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), $validated['notes'] ?? null));
$updatedSeat = $licenseSeat->load('license', 'user', 'asset');
});
if ($errorResponse) {
return $errorResponse;
}
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($updatedSeat), trans('admin/licenses/message.checkout.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
}
/**
* Checkin a license seat.
*
* `seat_id` is required to identify which seat to check back in.
*
* @param int $licenseId
*/
public function checkin(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkin', $license);
$validated = $this->validate($request, [
'seat_id' => 'required|integer',
'notes' => 'sometimes|string|nullable',
]);
$licenseSeat = LicenseSeat::where('id', $validated['seat_id'])
->where('license_id', $license->id)
->first();
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.not_found')));
}
if (is_null($licenseSeat->assigned_to) && is_null($licenseSeat->asset_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkin.error')));
}
$target = $licenseSeat->user ?? $licenseSeat->asset;
$licenseSeat->assigned_to = null;
$licenseSeat->asset_id = null;
$licenseSeat->notes = $validated['notes'] ?? null;
if (! $license->reassignable) {
$licenseSeat->unreassignable_seat = true;
}
if (! $licenseSeat->save()) {
return response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
}
event(new CheckoutableCheckedIn($licenseSeat, $target, auth()->user(), $licenseSeat->notes));
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat->load('license', 'user', 'asset')), trans('admin/licenses/message.checkin.success')));
}
/**
* Gets a paginated collection for the select2 menus
*
@@ -427,10 +427,6 @@ class LocationsController extends Controller
$locations = Company::scopeCompanyables($locations);
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$locations->where('locations.company_id', $request->input('companyId'));
}
$page = 1;
if ($request->filled('page')) {
$page = $request->input('page');
+8 -24
View File
@@ -22,7 +22,6 @@ use App\Models\Asset;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CurrentInventory;
use App\Notifications\WelcomeNotification;
@@ -52,6 +51,7 @@ class UsersController extends Controller
'users.address',
'users.avatar',
'users.city',
'users.company_id',
'users.country',
'users.created_by',
'users.created_at',
@@ -89,7 +89,7 @@ class UsersController extends Controller
])->with('manager')
->with('groups')
->with('userloc')
->with('companies')
->with('company')
->with('department')
->with('createdBy')
->withCount([
@@ -191,7 +191,7 @@ class UsersController extends Controller
}
if ($request->filled('company_id')) {
$users = $users->whereHas('companies', fn ($q) => $q->where('companies.id', $request->input('company_id')));
$users = $users->where('users.company_id', '=', $request->input('company_id'));
}
if ($request->filled('phone')) {
@@ -396,13 +396,6 @@ class UsersController extends Controller
]
)->where('show_in_list', '=', '1');
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$users->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
}
}
if ($request->filled('search')) {
$users = $users->where(function ($query) use ($request) {
$query->SimpleNameSearch($request->input('search'))
@@ -450,6 +443,7 @@ class UsersController extends Controller
$authenticatedUser = auth()->user();
$user = new User;
$user->fill($request->all());
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$user->created_by = auth()->id();
if ($request->has('permissions')) {
@@ -494,12 +488,6 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// Sync company memberships from company_ids[] or fall back to scalar company_id
$companyIds = array_filter(
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
);
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.create')));
}
@@ -589,6 +577,10 @@ class UsersController extends Controller
}
if ($request->filled('company_id')) {
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
}
if ($user->id == $request->input('manager_id')) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'You cannot be your own manager'));
}
@@ -617,14 +609,6 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// Sync company memberships when company_ids[] or company_id is provided
if ($request->has('company_ids') || $request->filled('company_id')) {
$companyIds = array_filter(
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
);
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
}
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.update')));
}
@@ -567,12 +567,11 @@ class AssetsController extends Controller
*
* @since [v3.0]
*/
public function getAssetBySerial(Request $request, $serial = null): RedirectResponse
public function getAssetBySerial(Request $request): RedirectResponse
{
$serial = $serial ?: $request->input('serial');
$topsearch = ($request->input('topsearch') == 'true');
if (! $asset = Asset::where('serial', '=', $serial)->first()) {
if (! $asset = Asset::where('serial', '=', $request->input('serial'))->first()) {
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
}
$this->authorize('view', $asset);
@@ -15,7 +15,6 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class LicenseCheckoutController extends Controller
@@ -95,31 +94,23 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
}
$licenseSeat = null;
$checkoutTarget = null;
DB::transaction(function () use ($request, $license, $seatId, &$licenseSeat, &$checkoutTarget): void {
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId, lock: true);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if ($request->filled('asset_id')) {
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
} elseif ($request->filled('assigned_to')) {
$checkoutTarget = $this->checkoutToUser($licenseSeat);
}
});
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if ($request->filled('asset_id')) {
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',
'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'),
@@ -165,11 +156,9 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('Something went wrong handling this checkout.'));
}
protected function findLicenseSeatToCheckout($license, $seatId, bool $lock = false)
protected function findLicenseSeatToCheckout($license, $seatId)
{
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->when($lock, fn ($q) => $q->lockForUpdate())->first()
: $license->freeSeat(lock: $lock);
$licenseSeat = LicenseSeat::find($seatId) ?? $license->freeSeat();
if (! $licenseSeat) {
if ($seatId) {
+2 -2
View File
@@ -277,7 +277,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users()->with('companies')->get())
->with('users', $location->users)
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -297,7 +297,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users()->with('companies')->get())
->with('users', $location->users)
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\ConsumableAssignment;
use App\Models\Group;
use App\Models\License;
@@ -169,6 +168,7 @@ class BulkUsersController extends Controller
$this->conditionallyAddItem('location_id')
->conditionallyAddItem('department_id')
->conditionallyAddItem('company_id')
->conditionallyAddItem('locale')
->conditionallyAddItem('remote')
->conditionallyAddItem('display_name')
@@ -200,7 +200,7 @@ class BulkUsersController extends Controller
$this->update_array['manager_id'] = null;
}
if ($request->input('null_company_ids') == '1') {
if ($request->input('null_company_id') == '1') {
$this->update_array['company_id'] = null;
}
@@ -233,22 +233,6 @@ class BulkUsersController extends Controller
->update(['location_id' => $this->update_array['location_id']]);
}
// Handle company pivot sync separately from the mass update.
// company_ids[] comes from the multi-select; null_company_ids clears all memberships.
$bulkCompanyIds = array_filter(array_map('intval', (array) $request->input('company_ids', [])));
$clearCompanies = $request->input('null_company_ids') == '1';
if ($bulkCompanyIds || $clearCompanies) {
$allowedIds = Company::getIdsForCurrentUser($bulkCompanyIds);
// Also update the scalar company_id column for display/backward compat.
$scalarCompanyId = $allowedIds[0] ?? null;
User::whereIn('id', $user_raw_array)->where('id', '!=', auth()->id())
->update(['company_id' => $scalarCompanyId]);
foreach ($users as $user) {
$user->companies()->sync($allowedIds);
}
}
// Fields that require canEditAuthFields (non-admins cannot touch admins/superusers,
// admins cannot touch superusers) must be applied per-user, not via mass update.
foreach ($users as $user) {
@@ -489,12 +473,6 @@ class BulkUsersController extends Controller
$managedLocation->save();
}
// Carry over company pivot memberships from the merged user into the target.
$mergedCompanyIds = $user_to_merge->companies()->pluck('companies.id')->toArray();
if (! empty($mergedCompanyIds)) {
$merge_into_user->companies()->syncWithoutDetaching($mergedCompanyIds);
}
$user_to_merge->delete();
event(new UserMerged($user_to_merge, $merge_into_user, $admin));
@@ -123,7 +123,7 @@ class UsersController extends Controller
$user->mobile = $request->input('mobile');
$user->location_id = $request->input('location_id', null);
$user->department_id = $request->input('department_id', null);
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->address = $request->input('address', null);
@@ -153,7 +153,6 @@ class UsersController extends Controller
}
if ($user->save()) {
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
if (($user->activated == '1') && ($user->email != '') && ($request->input('send_welcome') == '1')) {
@@ -276,7 +275,7 @@ class UsersController extends Controller
$user->phone = $request->input('phone');
$user->mobile = $request->input('mobile');
$user->location_id = $request->input('location_id', null);
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->department_id = $request->input('department_id', null);
@@ -337,8 +336,6 @@ class UsersController extends Controller
session()->put(['redirect_option' => $request->input('redirect_option')]);
if ($user->save()) {
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
// Redirect to the user page
return Helper::getRedirectOption($request, $user->id, 'Users')
->with('success', trans('admin/users/message.success.update'));
@@ -483,7 +480,7 @@ class UsersController extends Controller
$permissions = $request->input('permissions', []);
app('request')->request->set('permissions', $permissions);
$user_to_clone = User::with('userloc', 'companies')->withTrashed()->find($user->id);
$user_to_clone = User::with('userloc')->withTrashed()->find($user->id);
// Make sure they can view this particular user
$this->authorize('view', $user_to_clone);
@@ -601,7 +598,7 @@ class UsersController extends Controller
'manager',
'groups',
'userloc',
'companies',
'company',
'createdBy'
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
->orderBy('created_at', 'DESC')
@@ -623,7 +620,7 @@ class UsersController extends Controller
// Add a new row with data
$values = [
$user->id,
$user->companies->pluck('name')->implode('|'),
($user->company) ? $user->company->name : '',
$user->jobtitle,
$user->employee_num,
$user->first_name,
@@ -121,7 +121,6 @@ class ViewAssetsController extends Controller
'consumables',
'accessories',
'licenses',
'companies',
])->find($selectedUserId);
// If the user to view couldn't be found (shouldn't happen with proper logic), redirect with error
-2
View File
@@ -17,7 +17,6 @@ use App\Http\Middleware\PreventBackHistory;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetAPIResponseHeaders;
use App\Http\Middleware\SetPaginationDefaults;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\VerifyCsrfToken;
@@ -85,7 +84,6 @@ class Kernel extends HttpKernel
'auth:api',
CheckLocale::class,
LogAuthedUserHeader::class,
SetPaginationDefaults::class,
SubstituteBindings::class,
],
@@ -1,34 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetPaginationDefaults
{
public function handle(Request $request, Closure $next)
{
$limit = config('app.max_results');
$intLimit = intval($request->input('limit'));
if (abs($intLimit) > 0 && $intLimit <= config('app.max_results')) {
$limit = abs($intLimit);
}
app()->instance('api_limit_value', $limit);
if ($request->filled('page') && ! $request->filled('offset')) {
$page = max(1, intval($request->input('page')));
$offset = ($page - 1) * $limit;
} else {
$offset = intval($request->input('offset'));
$page = $limit > 0 ? (int) floor($offset / $limit) + 1 : 1;
}
app()->instance('api_offset_value', $offset);
app()->instance('api_current_page', $page);
return $next($request);
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ class ItemImportRequest extends FormRequest
$classString = "App\\Importer\\{$class}Importer";
$importer = new $classString($filename);
$import->field_map = request('column-mappings');
$import->created_by = $import->created_by ?? auth()->id();
$import->created_by = auth()->id();
$import->save();
$fieldMappings = [];
@@ -293,28 +293,6 @@ class ActionlogsTransformer
$clean_meta[trans('general.company')] = $clean_meta['company_id'];
unset($clean_meta['company_id']);
}
if (array_key_exists('companies', $clean_meta)) {
// clean_field() JSON-encodes array values into a string (e.g. "[14,15]").
// Decode them back to integer arrays before resolving names.
// Use withoutGlobalScopes so FMCS does not hide companies from the log viewer.
$resolveCompanyNames = function ($rawValue): string {
$ids = json_decode($rawValue, true);
if (empty($ids) || ! is_array($ids)) {
return trans('general.unassigned');
}
return collect($ids)
->map(fn ($id) => Company::withoutGlobalScopes()->withTrashed()->find($id))
->map(fn ($c) => $c ? e($c->name) : trans('general.deleted'))
->join(', ');
};
$clean_meta['companies']['old'] = $resolveCompanyNames($clean_meta['companies']['old']);
$clean_meta['companies']['new'] = $resolveCompanyNames($clean_meta['companies']['new']);
$clean_meta[trans('general.companies')] = $clean_meta['companies'];
unset($clean_meta['companies']);
}
if (array_key_exists('supplier_id', $clean_meta)) {
$oldSupplier = $supplier->find($clean_meta['supplier_id']['old']);
@@ -38,11 +38,13 @@ class LicenseSeatsTransformer
'tag_color' => $seat->user->department->tag_color ? e($seat->user->department->tag_color) : null,
] : null,
'companies' => $seat->user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'company' => ($seat->user->company) ?
[
'id' => (int) $seat->user->company->id,
'name' => e($seat->user->company->name),
'tag_color' => $seat->user->company->tag_color ? e($seat->user->company->tag_color) : null,
] : null,
'created_at' => Helper::getFormattedDateObject($seat->created_at, 'datetime'),
] : null,
'assigned_asset' => ($seat->asset) ? [
+4 -15
View File
@@ -82,17 +82,11 @@ class UsersTransformer
'consumables_count' => (int) $user->consumables_count,
'manages_users_count' => (int) $user->manages_users_count,
'manages_locations_count' => (int) $user->manages_locations_count,
// Legacy field — kept for backward API compatibility; use `companies` for multi-company support.
'company' => $user->companies->isNotEmpty() ? [
'id' => (int) $user->companies->first()->id,
'name' => e($user->companies->first()->name),
'tag_color' => ($user->companies->first()->tag_color) ? e($user->companies->first()->tag_color) : null,
'company' => ($user->company) ? [
'id' => (int) $user->company->id,
'name' => e($user->company->name),
'tag_color' => ($user->company->tag_color) ? e($user->company->tag_color) : null,
] : null,
'companies' => $user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'created_by' => ($user->createdBy) ? [
'id' => (int) $user->createdBy->id,
'name' => e($user->createdBy->display_name),
@@ -150,11 +144,6 @@ class UsersTransformer
'last_name' => e($user->last_name),
'username' => e($user->username),
'display_name' => e($user->display_name),
'companies' => $user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'created_by' => $user->adminuser ? [
'id' => (int) $user->adminuser->id,
'name' => e($user->adminuser->present()->fullName),
+5 -63
View File
@@ -3,7 +3,6 @@
namespace App\Importer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Department;
use App\Models\Setting;
use App\Models\User;
@@ -36,31 +35,6 @@ class UserImporter extends ItemImporter
$this->createUserIfNotExists($row);
}
/**
* Parse a pipe-separated company column value into an array of company IDs,
* creating companies that do not yet exist. Returns an empty array when the
* raw value is blank (so callers can treat that as "don't change").
*
* @param string $raw Raw cell value, e.g. "Acme Corp|Widget Inc"
* @return int[]
*/
private function resolveCompanyIds(string $raw): array
{
if ($raw === '') {
return [];
}
$ids = [];
foreach (array_filter(array_map('trim', explode('|', $raw))) as $name) {
$id = $this->createOrFetchCompany($name);
if ($id) {
$ids[] = (int) $id;
}
}
return Company::getIdsForCurrentUser($ids);
}
/**
* Create a user if a duplicate does not exist.
*
@@ -106,13 +80,6 @@ class UserImporter extends ItemImporter
$this->item['department_id'] = $this->createOrFetchDepartment($user_department);
}
// Resolve pipe-separated company names (e.g. "Acme Corp|Widget Inc") into IDs.
// company_id is a legacy column — company membership is managed via the pivot.
// Unset whatever the parent set so it is not written to the DB.
$companyRaw = trim($this->findCsvMatch($row, 'company'));
$companyIds = $this->resolveCompanyIds($companyRaw);
unset($this->item['company_id']);
if (is_null($this->item['username']) || $this->item['username'] == '') {
$user_full_name = $this->item['first_name'].' '.$this->item['last_name'];
$user_formatted_array = User::generateFormattedNameFromFullName($user_full_name, Setting::getSettings()->username_format);
@@ -137,13 +104,11 @@ class UserImporter extends ItemImporter
$this->log('Updating User');
// CLI imports run unauthenticated and are fully trusted; only restrict web-initiated imports.
// Note: unset must target $this->item, not the model — sanitizeItemForUpdating() reads from $this->item.
if (Auth::check() && (! Auth::user()->hasAccess('users.edit') || ! Gate::allows('canEditAuthFields', $user))) {
unset($this->item['username']);
unset($this->item['email']);
unset($this->item['password']);
unset($this->item['activated']);
if (Auth::check() && (! Gate::allows('canEditAuthFields', $user))) {
unset($user->username);
unset($user->email);
unset($user->password);
unset($user->activated);
}
$user->update($this->sanitizeItemForUpdating($user));
@@ -151,11 +116,6 @@ class UserImporter extends ItemImporter
// Why do we have to do this twice? Update should
$user->save();
// Sync company pivot when companies were specified in this row.
if (! empty($companyIds)) {
$user->companies()->sync($companyIds);
}
// Update the location of any assets checked out to this user
Asset::where('assigned_type', User::class)
->where('assigned_to', $user->id)
@@ -165,17 +125,6 @@ class UserImporter extends ItemImporter
return;
}
// With FMCS enabled, the scoped lookup above only sees users in the current user's companies.
// If the username exists in another company it would appear as "not found" and fall through
// to create — but usernames are unique system-wide, so we must skip instead.
if (Auth::check() && Company::isFullMultipleCompanySupportEnabled()) {
if (User::withoutGlobalScopes()->where('username', $this->item['username'])->exists()) {
$this->log('Skipping '.$this->item['username'].': username belongs to a user outside your company scope.');
return;
}
}
// This needs to be applied after the update logic, otherwise we'll overwrite user passwords
// Issue #5408
$this->item['password'] = $this->tempPassword;
@@ -191,13 +140,6 @@ class UserImporter extends ItemImporter
if ($user->save()) {
$this->log('User '.$this->item['name'].' was created');
// Sync all resolved companies to the pivot. For single-company rows the
// User::created event already added company_id; sync() here is idempotent
// for that case and adds any additional companies for multi-company rows.
if (! empty($companyIds)) {
$user->companies()->sync($companyIds);
}
if (($user->email) && ($user->activated == '1')) {
if ($this->send_welcome) {
+1 -2
View File
@@ -146,8 +146,7 @@ class AccessoryCheckout extends Model
$search_str = '%'.$term.'%';
$query->where('first_name', 'like', $search_str)
->orWhere('last_name', 'like', $search_str)
->orWhere('note', 'like', $search_str)
->orWhereHas('companies', fn ($q) => $q->where('companies.name', 'like', $search_str));
->orWhere('note', 'like', $search_str);
}
}
)->select('id');
+24 -106
View File
@@ -11,7 +11,6 @@ use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
@@ -95,26 +94,7 @@ final class Company extends SnipeModel
'notes',
];
/**
* Return the current user's company IDs by querying the pivot table directly.
*
* We deliberately bypass the Eloquent companies() relationship here because
* loading that relationship triggers CompanyableScope on the Company model,
* which calls this method again — infinite recursion.
*/
private static function getCurrentUserCompanyIds(): array
{
if (! Auth::hasUser()) {
return [];
}
return DB::table('company_user')
->where('user_id', auth()->id())
->pluck('company_id')
->toArray();
}
public static function isFullMultipleCompanySupportEnabled()
private static function isFullMultipleCompanySupportEnabled()
{
$settings = Setting::getSettings();
@@ -199,65 +179,20 @@ final class Company extends SnipeModel
}
if (auth()->user()) {
if (auth()->user()->isSuperUser()) {
return true;
// Log::warning('Companyable is '.$companyable);
$current_user_company_id = auth()->user()->company_id;
$companyable_company_id = $companyable->company_id;
// Set this to check companyable on company
if ($companyable instanceof Company) {
$companyable_company_id = $companyable->id;
}
$userCompanyIds = self::getCurrentUserCompanyIds();
// Empty pivot = unrestricted only for true legacy "no-company" users
// (those whose scalar company_id is also null). Users who had their
// pivot cleared via the API retain their scalar company_id, so they
// do NOT qualify for this bypass.
if (empty($userCompanyIds) && is_null(auth()->user()->company_id)) {
return true;
}
// Users are scoped by pivot membership, not company_id, so check the pivot directly.
if ($companyable instanceof User) {
$companyableCompanyIds = DB::table('company_user')
->where('user_id', $companyable->id)
->pluck('company_id')
->toArray();
// A user with no pivot rows is a null-company user; no intersection is possible.
if (empty($companyableCompanyIds)) {
return false;
}
return ! empty(array_intersect($userCompanyIds, $companyableCompanyIds));
}
$companyable_company_id = ($companyable instanceof Company)
? $companyable->id
: $companyable->company_id;
return in_array($companyable_company_id, $userCompanyIds);
return ($current_user_company_id == null) || ($current_user_company_id == $companyable_company_id) || auth()->user()->isSuperUser();
}
return false;
}
/**
* Filter an array of requested company IDs to only those the current user
* belongs to. Superusers may assign any company; non-superusers are limited
* to their own pivot memberships when FMCS is enabled.
*/
public static function getIdsForCurrentUser(array $requestedIds): array
{
if (! self::isFullMultipleCompanySupportEnabled()) {
return $requestedIds;
}
$current_user = auth()->user();
if ($current_user->isSuperUser()) {
return $requestedIds;
}
$allowedIds = self::getCurrentUserCompanyIds();
return array_values(array_intersect($requestedIds, $allowedIds));
}
public static function isCurrentUserAuthorized()
@@ -267,9 +202,8 @@ final class Company extends SnipeModel
public static function canManageUsersCompanies()
{
return ! self::isFullMultipleCompanySupportEnabled()
|| auth()->user()->isSuperUser()
|| empty(self::getCurrentUserCompanyIds());
return ! self::isFullMultipleCompanySupportEnabled() || auth()->user()->isSuperUser() ||
auth()->user()->company_id == null;
}
/**
@@ -308,7 +242,7 @@ final class Company extends SnipeModel
public function users()
{
return $this->belongsToMany(User::class, 'company_user');
return $this->hasMany(User::class, 'company_id');
}
public function assets()
@@ -370,43 +304,27 @@ final class Company extends SnipeModel
*/
private static function scopeCompanyablesDirectly($query, $column = 'company_id', $table_name = null)
{
$companyIds = self::getCurrentUserCompanyIds();
$company_id = null;
// Get the company ID of the logged-in user, or set it to null if there is no company associated with the user
if (Auth::hasUser()) {
$company_id = auth()->user()->company_id;
}
// If we are scoping the companies table itself, look for the company.id
if ($query->getModel()->getTable() == 'companies') {
if (empty($companyIds)) {
return $query->whereNull('companies.id');
}
return $query->whereIn('companies.id', $companyIds);
}
// Users are scoped by pivot membership (company_user), not by company_id column,
// since a user may belong to multiple companies and company_id alone is insufficient.
if ($query->getModel()->getTable() == 'users') {
if (empty($companyIds)) {
// No pivot memberships: mirror old null-company behavior — show only users
// who are also not in any company via the pivot.
return $query->whereNotIn('users.id', function ($sub) {
$sub->select('user_id')->from('company_user');
});
}
return $query->whereIn('users.id', function ($sub) use ($companyIds) {
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
});
return $query->where('companies.id', '=', $company_id);
}
// If the column exists in the table, use it to scope the query
if ($query && $query->getModel() && Schema::hasColumn($query->getModel()->getTable(), $column)) {
if ((($query) && ($query->getModel()) && (Schema::hasColumn($query->getModel()->getTable(), $column)))) {
// Dynamically get the table name if it's not passed in, based on the model we're querying against
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
if (empty($companyIds)) {
return $query->whereNull($table.$column);
}
return $query->whereIn($table.$column, $companyIds);
return $query->where($table.$column, '=', $company_id);
}
}
/**
+1 -2
View File
@@ -803,7 +803,7 @@ class License extends Depreciable
*
* @return mixed
*/
public function freeSeat(bool $lock = false)
public function freeSeat()
{
return $this->licenseseats()
->whereNull('deleted_at')
@@ -813,7 +813,6 @@ class License extends Depreciable
->whereNull('asset_id');
})
->orderBy('id', 'asc')
->when($lock, fn ($q) => $q->lockForUpdate())
->first();
}
+3 -75
View File
@@ -18,7 +18,6 @@ use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -60,13 +59,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
protected $injectUniqueIdentifier = true;
/**
* Transient (non-persisted) ID of the Actionlog entry written by UserObserver::updating()
* during the current request. syncCompaniesWithLogging() merges company changes into this
* entry instead of creating a separate one, so a single edit session produces one log row.
*/
public ?int $currentUpdateLogId = null;
protected $fillable = [
'activated',
'address',
@@ -174,7 +166,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
'userloc' => ['name', 'address', 'address2', 'city', 'state', 'zip'],
'department' => ['name'],
'groups' => ['name'],
'companies' => ['name'],
'company' => ['name'],
'manager' => ['first_name', 'last_name', 'username', 'display_name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
@@ -252,15 +244,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
protected static function booted(): void
{
// Bridge for factories/seeders that still set company_id directly: ensure
// that company appears in the pivot so FMCS scoping works correctly.
// Application code (controllers, importers) writes only to the pivot.
static::created(function (User $user) {
if ($user->company_id) {
$user->companies()->syncWithoutDetaching([$user->company_id]);
}
});
static::forceDeleted(function (User $user) {
CheckoutRequest::where(['user_id' => $user->id])->forceDelete();
$user->purgeAssociatedPassportTokens();
@@ -620,51 +603,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return $this->belongsTo(Company::class, 'company_id');
}
public function companies(): BelongsToMany
{
return $this->belongsToMany(Company::class, 'company_user');
}
/**
* Sync company pivot membership and log the change if the set of companies changed.
*
* When called after $user->save() in the same request, UserObserver::updating() will
* have already written an Actionlog row and stored its ID in $this->currentUpdateLogId.
* In that case we merge the company change into that existing entry so that a single
* edit session (field changes + company changes) produces one log row, not two.
*/
public function syncCompaniesWithLogging(array $companyIds): void
{
$oldIds = $this->companies()->orderBy('companies.id')->pluck('companies.id')->toArray();
$this->companies()->sync($companyIds);
$newIds = $this->companies()->orderBy('companies.id')->pluck('companies.id')->toArray();
if ($oldIds === $newIds) {
return;
}
$companyChange = ['companies' => ['old' => $oldIds, 'new' => $newIds]];
if ($this->currentUpdateLogId && ($existing = Actionlog::find($this->currentUpdateLogId))) {
$meta = json_decode($existing->log_meta ?? '{}', true) ?: [];
$existing->log_meta = json_encode(array_merge($meta, $companyChange));
$existing->save();
$this->currentUpdateLogId = null;
return;
}
$logAction = new Actionlog;
$logAction->item_type = static::class;
$logAction->item_id = $this->id;
$logAction->target_type = static::class;
$logAction->target_id = $this->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->log_meta = json_encode($companyChange);
$logAction->logaction('update');
}
/**
* Establishes the user -> department relationship
*
@@ -787,10 +725,9 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
{
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at');
}
public function directLicenses()
{
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
return $this->belongsToMany(\App\Models\License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
}
/**
@@ -1401,14 +1338,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
*/
public function scopeOrderCompany($query, $order)
{
$sub = DB::table('company_user')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->select('company_user.user_id', DB::raw('MIN(companies.name) as min_company_name'))
->groupBy('company_user.user_id');
return $query
->leftJoinSub($sub, 'companies_sort', 'companies_sort.user_id', '=', 'users.id')
->orderBy('companies_sort.min_company_name', $order);
return $query->leftJoin('companies as companies_user', 'users.company_id', '=', 'companies_user.id')->orderBy('companies_user.name', $order);
}
/**
@@ -1463,7 +1393,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
->orwhereRaw('CONCAT(users.first_name," ",users.last_name) LIKE \''.$search.'%\'');
}
public function scopeWithInventoryRelations($query, int $id)
{
return $query->where('id', $id)
@@ -1505,7 +1434,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
])
->withTrashed();
}
/**
* Get all direct and indirect subordinates for this user.
*
+11 -42
View File
@@ -16,8 +16,6 @@ class UserObserver
{
// ONLY allow these fields to be stored
// NOTE: company_id is intentionally excluded — company membership changes are logged
// via User::syncCompaniesWithLogging() against the pivot table instead.
$allowed_fields = [
'email',
'activated',
@@ -33,6 +31,7 @@ class UserObserver
'employee_num',
'username',
'notes',
'company_id',
'ldap_import',
'locale',
'two_factor_enrolled',
@@ -59,44 +58,18 @@ class UserObserver
// Make sure the info is in the allow fields array
if (in_array($key, $allowed_fields)) {
$oldValue = $user->getRawOriginal()[$key];
$newValue = $user->getAttributes()[$key];
// Check and see if the value changed
if ($user->getRawOriginal()[$key] != $user->getAttributes()[$key]) {
if ($key === 'permissions') {
// Compare decoded to avoid spurious diffs from key reordering or type coercion.
$oldDecoded = json_decode($oldValue ?? '{}', true) ?: [];
$newDecoded = json_decode($newValue ?? '{}', true) ?: [];
if ($oldDecoded == $newDecoded) {
continue;
$changed[$key]['old'] = $user->getRawOriginal()[$key];
$changed[$key]['new'] = $user->getAttributes()[$key];
// Do not store the hashed password in changes
if ($key == 'password') {
$changed['password']['old'] = '*************';
$changed['password']['new'] = '*************';
}
// Only log the permission keys that actually changed.
$diffOld = [];
$diffNew = [];
foreach (array_unique(array_merge(array_keys($oldDecoded), array_keys($newDecoded))) as $permKey) {
$oldPerm = $oldDecoded[$permKey] ?? null;
$newPerm = $newDecoded[$permKey] ?? null;
if ($oldPerm != $newPerm) {
$diffOld[$permKey] = $oldPerm;
$diffNew[$permKey] = $newPerm;
}
}
$changed['permissions']['old'] = json_encode($diffOld);
$changed['permissions']['new'] = json_encode($diffNew);
continue;
}
if ($oldValue == $newValue) {
continue;
}
$changed[$key]['old'] = $oldValue;
$changed[$key]['new'] = $newValue;
// Do not store the hashed password in changes
if ($key == 'password') {
$changed['password']['old'] = '*************';
$changed['password']['new'] = '*************';
}
}
@@ -106,16 +79,12 @@ class UserObserver
$logAction = new Actionlog;
$logAction->item_type = User::class;
$logAction->item_id = $user->id;
$logAction->target_type = User::class;
$logAction->target_type = User::class; // can we instead say $logAction->item = $asset ?
$logAction->target_id = $user->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->log_meta = json_encode($changed);
$logAction->logaction('update');
// Let syncCompaniesWithLogging() merge company changes into this entry
// rather than creating a separate log row for the same edit session.
$user->currentUpdateLogId = $logAction->id;
}
}
-9
View File
@@ -218,15 +218,6 @@ class AccessoryPresenter extends Presenter
'visible' => true,
'formatter' => 'polymorphicItemFormatter',
],
[
'field' => 'assigned_to.companies',
'searchable' => true,
'sortable' => false,
'switchable' => true,
'title' => trans('general.companies'),
'visible' => true,
'formatter' => 'companiesArrayLinkFormatter',
],
[
'field' => 'note',
'searchable' => false,
+3 -3
View File
@@ -280,13 +280,13 @@ class LicensePresenter extends Presenter
'formatter' => 'emailFormatter',
],
[
'field' => 'assigned_user.companies',
'field' => 'assigned_user.company',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('general.companies'),
'title' => trans('general.company'),
'visible' => true,
'formatter' => 'companiesArrayLinkFormatter',
'formatter' => 'companiesLinkObjFormatter',
],
[
'field' => 'assigned_user.department',
+4 -4
View File
@@ -83,13 +83,13 @@ class UserPresenter extends Presenter
'formatter' => 'usersLinkFormatter',
],
[
'field' => 'companies',
'field' => 'company',
'searchable' => true,
'sortable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.companies'),
'title' => trans('admin/companies/table.title'),
'visible' => false,
'formatter' => 'companiesArrayLinkFormatter',
'formatter' => 'companiesLinkObjFormatter',
],
[
'field' => 'employee_num',
-6
View File
@@ -103,11 +103,5 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(config('auth.password_reset.max_attempts_per_min'))->by(optional($request->user())->id ?: $request->ip());
});
// Rate limiter for two-factor authentication — keyed on user ID since the user is already
// password-authenticated at this stage, preventing distributed brute force across IPs.
RateLimiter::for('two_factor', function (Request $request) {
return Limit::perMinute(config('auth.two_factor.max_attempts_per_min'))->by(optional($request->user())->id ?: $request->ip());
});
}
}
+37
View File
@@ -32,6 +32,43 @@ class SettingsServiceProvider extends ServiceProvider
$view->with('snipeSettings', Setting::getSettings());
});
// Make sure the limit is actually set, is an integer and does not exceed system limits
app()->singleton('api_limit_value', function () {
$limit = config('app.max_results');
$int_limit = intval(request('limit'));
if ((abs($int_limit) > 0) && ($int_limit <= config('app.max_results'))) {
$limit = abs($int_limit);
}
return $limit;
});
// Make sure the offset is actually set and is an integer.
// If 'page' is passed without 'offset', derive the offset from the page number.
app()->singleton('api_offset_value', function () {
if (request()->filled('page') && ! request()->filled('offset')) {
$page = max(1, intval(request('page')));
return ($page - 1) * (int) app('api_limit_value');
}
return intval(request('offset'));
});
// Resolve the current page number for inclusion in API list responses.
// Supports both page= and legacy offset= parameters.
app()->singleton('api_current_page', function () {
if (request()->filled('page') && ! request()->filled('offset')) {
return max(1, intval(request('page')));
}
$limit = (int) app('api_limit_value');
$offset = (int) app('api_offset_value');
return $limit > 0 ? (int) floor($offset / $limit) + 1 : 1;
});
/**
* Set some common variables so that they're globally available.
* The paths should always be public (versus private uploads)
+2 -7
View File
@@ -353,15 +353,10 @@ class ValidationServiceProvider extends ServiceProvider
Validator::extend('fmcs_location', function ($attribute, $value, $parameters, $validator) {
$settings = Setting::getSettings();
if ($settings->full_multiple_companies_support == '1' && $settings->scope_locations_fmcs == '1') {
$data = $validator->getData();
// Support both multi-company (company_ids[]) and single-company (company_id) requests
$companyIds = array_filter(array_unique(array_merge(
(array) ($data['company_ids'] ?? []),
[$data['company_id'] ?? null]
)));
$company_id = array_get($validator->getData(), 'company_id');
$location = Location::find($value);
if ($location && ! in_array($location->company_id, $companyIds)) {
if (($location) && ($company_id != $location->company_id)) {
return false;
}
}
-1
View File
@@ -19,7 +19,6 @@
"require": {
"php": "^8.2",
"ext-curl": "*",
"ext-exif": "*",
"ext-fileinfo": "*",
"ext-iconv": "*",
"ext-json": "*",
-4
View File
@@ -122,10 +122,6 @@ return [
'max_attempts_per_min' => env('PASSWORD_RESET_MAX_ATTEMPTS_PER_MIN', 50),
],
'two_factor' => [
'max_attempts_per_min' => env('TWO_FACTOR_MAX_ATTEMPTS_PER_MIN', 5),
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
+6 -6
View File
@@ -1,11 +1,11 @@
<?php
return [
'app_version' => 'v8.5.1-pre',
'full_app_version' => 'v8.5.1-pre - build 22809-g86245ad4ae',
'build_version' => '22809',
'app_version' => 'v8.6.0',
'full_app_version' => 'v8.6.0 - build 22854-gcfa8069953',
'build_version' => '22854',
'prerelease_version' => '',
'hash_version' => 'g86245ad4ae',
'full_hash' => 'v8.5.1-pre-185-g86245ad4ae',
'branch' => 'develop',
'hash_version' => 'gcfa8069953',
'full_hash' => 'v8.6.0-195-gcfa8069953',
'branch' => 'master',
];
@@ -1,38 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('company_user', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('company_id')->index();
$table->unsignedInteger('user_id')->index();
$table->timestamps();
$table->unique(['company_id', 'user_id']);
});
// Seed pivot from existing users.company_id values
DB::table('users')
->whereNotNull('company_id')
->orderBy('id')
->each(function ($user) {
DB::table('company_user')->insertOrIgnore([
'company_id' => $user->company_id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
});
}
public function down(): void
{
Schema::dropIfExists('company_user');
}
};
+20 -37
View File
@@ -33,18 +33,27 @@ class UserSeeder extends Seeder
$departmentIds = Department::all()->pluck('id');
// Named admins get multiple companies — they manage assets across several organisations.
foreach (['firstAdmin', 'snipeAdmin', 'testAdmin'] as $state) {
$user = User::factory()->{$state}()->create([
'company_id' => null,
User::factory()->count(1)->firstAdmin()
->state(new Sequence(fn ($sequence) => [
'company_id' => $companyIds->random(),
'department_id' => $departmentIds->random(),
]);
$ids = $companyIds->random(min(rand(2, 3), $companyIds->count()))->toArray();
User::where('id', $user->id)->update(['company_id' => $ids[0]]);
$user->companies()->sync($ids);
}
]))
->create();
User::factory()->count(1)->snipeAdmin()
->state(new Sequence(fn ($sequence) => [
'company_id' => $companyIds->random(),
'department_id' => $departmentIds->random(),
]))
->create();
User::factory()->count(1)->testAdmin()
->state(new Sequence(fn ($sequence) => [
'company_id' => $companyIds->random(),
'department_id' => $departmentIds->random(),
]))
->create();
// Superusers — one company each.
User::factory()->count(3)->superuser()
->state(new Sequence(fn ($sequence) => [
'company_id' => $companyIds->random(),
@@ -52,7 +61,6 @@ class UserSeeder extends Seeder
]))
->create();
// Admins — one company each.
User::factory()->count(3)->admin()
->state(new Sequence(fn ($sequence) => [
'company_id' => $companyIds->random(),
@@ -60,38 +68,13 @@ class UserSeeder extends Seeder
]))
->create();
// Regular users — three groups:
// ~30 % (600) no company
// ~50 % (1 000) one company
// ~20 % (400) two or three companies
User::factory()->count(600)->viewAssets()
->state(new Sequence(fn ($sequence) => [
'company_id' => null,
'department_id' => $departmentIds->random(),
]))
->create();
User::factory()->count(1000)->viewAssets()
User::factory()->count(2000)->viewAssets()
->state(new Sequence(fn ($sequence) => [
'company_id' => $companyIds->random(),
'department_id' => $departmentIds->random(),
]))
->create();
$multiCompanyUsers = User::factory()->count(400)->viewAssets()
->state(new Sequence(fn ($sequence) => [
'company_id' => null,
'department_id' => $departmentIds->random(),
]))
->create();
foreach ($multiCompanyUsers as $user) {
$ids = $companyIds->random(min(rand(2, 3), $companyIds->count()))->toArray();
User::where('id', $user->id)->update(['company_id' => $ids[0]]);
$user->companies()->sync($ids);
}
$src = public_path('/img/demo/avatars/');
$dst = 'avatars'.'/';
$del_files = Storage::files($dst);
+1 -1
View File
@@ -51,4 +51,4 @@ SECURE_COOKIES=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
+1 -1
View File
@@ -60,4 +60,4 @@ SECURE_COOKIES=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
+1 -1
View File
@@ -23,7 +23,7 @@
<env name="CACHE_DRIVER" value="array"/>
<env name="MAIL_FROM_ADDR" value="app@example.com"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<ini name="display_errors" value="true"/>
</php>
+2 -53227
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -1,9 +1,9 @@
{
"/js/dist/all.js": "/js/dist/all.js?id=4619b48bfce17ad41fc5a2e9ee578988",
"/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",
"/css/dist/all.css": "/css/dist/all.css?id=57e6bf27bcfad47e58a82b9842a7d5bd",
"/js/dist/all.js": "/js/dist/all.js?id=a7ea6cdd7a7105bc604ce52bf82f5920",
"/css/build/overrides.css": "/css/build/overrides.css?id=9bfab28a94932d45568ad50f3c6c5e2c",
"/css/build/app.css": "/css/build/app.css?id=4b2abd7fa3560ada549e9d08bd836aa8",
"/css/build/AdminLTE.css": "/css/build/AdminLTE.css?id=bdf169bc2141f453390614c138cdce95",
"/css/dist/all.css": "/css/dist/all.css?id=f5f404325dedd1abd00dc781664c0034",
"/css/dist/signature-pad.css": "/css/dist/signature-pad.css?id=6a89d3cd901305e66ced1cf5f13147f7",
"/css/dist/signature-pad.min.css": "/css/dist/signature-pad.min.css?id=6a89d3cd901305e66ced1cf5f13147f7",
"/js/select2/i18n/af.js": "/js/select2/i18n/af.js?id=4f6fcd73488ce79fae1b7a90aceaecde",
+1 -1
View File
@@ -210,7 +210,7 @@ $(function () {
search: params.term,
page: params.page || 1,
statusType: link.data("asset-status-type"),
companyId: link.data("company-ids") || link.data("company-id"),
companyId: link.data("company-id"),
};
return data;
},
-1
View File
@@ -85,7 +85,6 @@ return [
'click_here' => 'Click here',
'clear_selection' => 'Clear Selection',
'companies' => 'Companies',
'companies_var' => 'Company|Companies',
'company' => 'Company',
'component' => 'Component',
'components' => 'Components',
@@ -219,19 +219,14 @@
<!-- company -->
@if ($user->companies->isNotEmpty())
@if (!is_null($user->company))
<div class="row">
<div class="col-md-3">
{{ trans_choice('general.companies_var', $user->companies->count()) }}
{{ trans('general.company') }}
</div>
<div class="col-md-9">
@foreach ($user->companies as $userCompany)
<span class="label label-light">{!! $userCompany->present()->formattedNameLink !!}</span>
@if (!$loop->last)
&nbsp;
@endif
@endforeach
{!! $user->company->present()->formattedNameLink !!}
</div>
</div>
@@ -221,30 +221,10 @@
@endif
@if ($infoPanelObj->companies)
@if ($infoPanelObj->companies->count() > 1)
<x-info-element icon_type="company" title="{{ trans('general.companies') }}">
{{ trans('general.companies') }}
<x-info-element class="subitem">
<x-copy-to-clipboard class="pull-right" copy_what="companies">
@foreach ($infoPanelObj->companies as $company)
{!! $company->present()->formattedNameLink !!}<br>
@endforeach
</x-copy-to-clipboard>
</x-info-element>
</x-info-element>
@else
<x-info-element icon_type="company" icon_color="{{ $infoPanelObj->companies->first()->tag_color }}" title="{{ trans('general.company') }}">
<x-copy-to-clipboard class="pull-right" copy_what="company">
{!! $infoPanelObj->companies->first()->present()->nameUrl !!}
</x-copy-to-clipboard>
</x-info-element>
@endif
@elseif ($infoPanelObj->company)
@if ($infoPanelObj->company)
<x-info-element icon_type="company" icon_color="{{ $infoPanelObj->company->tag_color }}" title="{{ trans('general.company') }}">
<x-copy-to-clipboard class="pull-right" copy_what="company">
{!! $infoPanelObj->company->present()->nameUrl !!}
{!! $infoPanelObj->company->present()->nameUrl !!}
</x-copy-to-clipboard>
</x-info-element>
@endif
@@ -9,7 +9,6 @@
'multiple' => false,
'helpText' => null,
'hideNewButton' => false,
'companyId' => null,
])
@php
@@ -42,9 +41,6 @@
@if ($multiple)
multiple
@endif
@if ($companyId)
data-company-id="{{ $companyId }}"
@endif
>
<option value=""></option>
@if ($selected)
@@ -79,7 +79,7 @@
<!-- User -->
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.select_user'), 'fieldname' => 'assigned_to', 'required'=> 'true', 'company_id' => $consumable->company_id])
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.select_user'), 'fieldname' => 'assigned_to', 'required'=> 'true'])
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $consumable->getEula() || ($snipeSettings->webhook_endpoint!=''))
+1 -1
View File
@@ -67,7 +67,7 @@
@endif
<!-- Locations -->
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.location'), 'fieldname' => 'location_id', 'company_id' => $asset->company_id])
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.location'), 'fieldname' => 'location_id'])
<!-- Update location -->
<div class="form-group">
@@ -141,7 +141,6 @@
name="location_id"
:help_text="($asset->defaultLoc) ? trans('general.checkin_to_diff_location', ['default_location' => $asset->defaultLoc->name]) : null"
:selected="old('location_id')"
:company_id="$asset->company_id"
/>
<!-- Update actual location -->
+2 -2
View File
@@ -119,10 +119,10 @@
@endif
@include ('partials.forms.checkout-selector', ['user_select' => 'true','asset_select' => 'true', 'location_select' => 'true'])
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.user'), 'fieldname' => 'assigned_user', 'company_id' => $asset->company_id, 'style' => (session('checkout_to_type') ?: 'user') == 'user' ? '' : 'display: none;'])
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.user'), 'fieldname' => 'assigned_user', 'style' => (session('checkout_to_type') ?: 'user') == 'user' ? '' : 'display: none;'])
<!-- We have to pass unselect here so that we don't default to the asset that's being checked out. We want that asset to be pre-selected everywhere else. -->
@include ('partials.forms.edit.asset-select', ['translated_name' => trans('general.select_asset'), 'fieldname' => 'assigned_asset', 'company_id' => $asset->company_id, 'unselect' => 'true', 'style' => session('checkout_to_type') == 'asset' ? '' : 'display: none;'])
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.location'), 'fieldname' => 'assigned_location', 'company_id' => $asset->company_id, 'style' => session('checkout_to_type') == 'location' ? '' : 'display: none;'])
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.location'), 'fieldname' => 'assigned_location', 'style' => session('checkout_to_type') == 'location' ? '' : 'display: none;'])
+2 -20
View File
@@ -44,7 +44,6 @@
:root {
color-scheme: light dark;
--color-bg: light-dark(#ecf0f5, #222222);
--btn-theme-hover-text-color: {{ $nav_link_color ?? 'light-dark(hsl(from var(--main-theme-color) h s calc(l - 10)),hsl(from var(--main-theme-color) h s calc(l - 10)))' }};
--btn-theme-hover: {{ $nav_link_color ?? 'light-dark(hsl(from var(--main-theme-color) h s calc(l - 10)),hsl(from var(--main-theme-color) h s calc(l - 10)))' }};
--btn-theme-text-color: {{ $nav_link_color ?? 'light-dark(hsl(from var(--main-theme-color) h s calc(l + 10)),hsl(from var(--main-theme-color) h s calc(l - 10)))' }};
@@ -70,10 +69,6 @@
--text-success: light-dark(#039516,#4ced61);
--text-warning: light-dark(#da9113,#f3a51f);
--input-border-color: light-dark(#d2d6de,#656464);
--default-label-link-bg: var(--color-bg);
--default-label-link-text: light-dark({{ $link_light_color ?? '#296282' }}, {{ $link_dark_color ?? '#5fa4cc' }});
--default-label-link-border: 1px solid light-dark(#b8c7ce, #494747);
}
[data-theme="light"] {
@@ -89,6 +84,7 @@
--btn-theme-hover: var(--main-theme-hover);
--callout-bg-color: var(--box-header-bottom-border-color);
--callout-left-border: var(--box-header-top-border-color);
--color-bg: #ecf0f5;
--header-color: #000000;
--input-group-bg: hsl(from var(--box-bg) h s calc(l - 5));
--input-group-fg: hsl(from var(--input-group-bg) h s calc(l - 50));
@@ -113,6 +109,7 @@
--btn-theme-hover: var(--main-theme-hover);
--callout-bg-color: var(--box-header-top-border-color);
--callout-left-border: #323131;
--color-bg: #222222;
--header-color: #ffffff;
--input-group-bg: hsl(from var(--box-bg) h s calc(l + 10));
--input-group-fg: hsl(from var(--input-group-bg) h s calc(l + 50));
@@ -582,21 +579,6 @@
color: var(--nav-primary-text-color) !important;
}
.label-light {
background-color: var(--default-label-link-bg) !important;
color: var(--color-fg) !important;
font-size: 12px !important;
font-weight: normal !important;
line-height: 25px;
margin-left: 0px;
padding-left: 3px;
}
a.label-light,
a.label-light:hover {
color: var(--link-color) !important;
}
.dropdown-menu > li > a,
.dropdown-menu > li > a:link,
+2 -2
View File
@@ -74,8 +74,8 @@
@endif
@include ('partials.forms.checkout-selector', ['user_select' => 'true','asset_select' => 'true', 'location_select' => 'false'])
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.user'), 'fieldname' => 'assigned_to', 'company_id' => $license->company_id, 'style' => (session('checkout_to_type') ?: 'user') == 'user' ? '' : 'display: none;'])
@include ('partials.forms.edit.asset-select', ['translated_name' => trans('general.select_asset'), 'fieldname' => 'asset_id', 'company_id' => $license->company_id, 'style' => session('checkout_to_type') == 'asset' ? '' : 'display: none;'])
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.user'), 'fieldname' => 'assigned_to', 'style' => (session('checkout_to_type') ?: 'user') == 'user' ? '' : 'display: none;'])
@include ('partials.forms.edit.asset-select', ['translated_name' => trans('general.select_asset'), 'fieldname' => 'asset_id', 'style' => session('checkout_to_type') == 'asset' ? '' : 'display: none;'])
<!-- Note -->
<div class="form-group {{ $errors->has('notes') ? 'error' : '' }}">
+1 -1
View File
@@ -93,7 +93,7 @@
<tr>
<td>{{ $counter }}</td>
<td>{{ ($user) ? $user->companies->pluck('name')->implode(', ') : '' }}</td>
<td>{{ (($user) && ($user->company)) ? $user->company->name : '' }}</td>
<td>{{ ($user) ? $user->first_name .' '. $user->last_name : '' }}</td>
<td>{{ ($user) ? $user->employee_num : '' }}</td>
<td>{{ (($user) && ($user->department)) ? $user->department->name : '' }}</td>
+2 -2
View File
@@ -16,8 +16,8 @@
</div>
<!-- Setup of default company, taken from asset creator if scoped locations are activated in the settings -->
@if (($snipeSettings->scope_locations_fmcs == '1') && ($user->companies->isNotEmpty()))
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->companies->first()->id }}' class="form-control">
@if (($snipeSettings->scope_locations_fmcs == '1') && ($user->company))
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->company->id }}' class="form-control">
@endif
<!-- Select company, only for users with multicompany access - replace default company -->
+2 -2
View File
@@ -30,8 +30,8 @@
<div class="alert alert-danger" id="modal_error_msg" style="display:none">
</div>
<!-- Setup of default company, taken from asset creator -->
@if ($user->companies->isNotEmpty())
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->companies->first()->id }}' class="form-control">
@if ($user->company)
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->company->id }}' class="form-control">
@endif
<!-- Select company, only for users with multicompany access - replace default company -->
@@ -2001,7 +2001,7 @@
if (value) {
var groups = '';
for (var index in value.rows) {
groups += '<a href="{{ config('app.url') }}/admin/groups/' + value.rows[index].id + '" class="label label-light">' + value.rows[index].name + '</a> ';
groups += '<a href="{{ config('app.url') }}/admin/groups/' + value.rows[index].id + '" class="label label-default">' + value.rows[index].name + '</a> ';
}
return groups;
}
@@ -2178,24 +2178,6 @@
}
}
function companiesLinkObjFormatter(value, row) {
if (!value) {
return '';
}
var icon = (value.tag_color) ? '<i class="fa-solid fa-square" style="color: ' + value.tag_color + ';" aria-hidden="true"></i> ' : '';
return '<a href="{{ config('app.url') }}/companies/' + value.id + '" class="label label-light">' + icon + value.name + '</a>';
}
function companiesArrayLinkFormatter(value, row) {
if (!value || !value.length) {
return '';
}
return value.map(function (c) {
var icon = (c.tag_color) ? '<i class="fa-solid fa-square" style="color: ' + c.tag_color + ';" aria-hidden="true"></i> ' : '';
return '<a href="{{ config('app.url') }}/companies/' + c.id + '" class="label label-light">' + icon + c.name + '</a></span>';
}).join(' ');
}
function locationCompanyObjFilterFormatter(value, row) {
if (value) {
return '<a href="{{ url('/') }}/locations/?company_id=' + row.company.id + '">' + row.company.name + '</a>';
@@ -5,7 +5,7 @@
<label for="{{ $fieldname }}" class="col-md-3 control-label">{{ $translated_name }}</label>
<div class="col-md-6">
<select class="js-data-ajax" disabled data-endpoint="companies"
data-placeholder="{{ trans('general.select_company') }}" name="{{ $fieldname }}{{ (isset($multiple) && ($multiple=='true')) ? '[]' : '' }}" style="width: 100%"
data-placeholder="{{ trans('general.select_company') }}" name="{{ $fieldname }}" style="width: 100%"
aria-label="{{ $fieldname }}"{{ (isset($multiple) && ($multiple=='true')) ? " multiple='multiple'" : '' }}>
@if ($company_id = old($fieldname, (isset($item)) ? $item->{$fieldname} : ''))
<option value="{{ $company_id }}" selected="selected" role="option" aria-selected="true" role="option">
@@ -22,8 +22,8 @@
<!-- full company support is enabled or this user is a superadmin -->
<div id="{{ $fieldname }}" class="form-group{{ $errors->has($fieldname) ? ' has-error' : '' }}">
<label for="{{ $fieldname }}" class="col-md-3 control-label">{{ $translated_name }}</label>
<div class="col-md-6">
<select class="js-data-ajax" data-endpoint="companies" data-placeholder="{{ trans('general.select_company') }}" name="{{ $fieldname }}{{ (isset($multiple) && ($multiple=='true')) ? '[]' : '' }}" style="width: 100%"{{ (isset($multiple) && ($multiple=='true')) ? " multiple='multiple'" : '' }}>
<div class="col-md-8">
<select class="js-data-ajax" data-endpoint="companies" data-placeholder="{{ trans('general.select_company') }}" name="{{ $fieldname }}" style="width: 100%"{{ (isset($multiple) && ($multiple=='true')) ? " multiple='multiple'" : '' }}>
@isset ($selected)
@foreach ($selected as $company_id)
<option value="{{ $company_id }}" selected="selected" role="option" aria-selected="true">
@@ -3,7 +3,7 @@
<label for="{{ $fieldname }}" class="col-md-3 control-label">{{ $translated_name }}</label>
<div class="col-md-7">
<select class="js-data-ajax" data-endpoint="locations" data-placeholder="{{ trans('general.select_location') }}" name="{{ $fieldname }}" style="width: 100%" id="{{ $fieldname }}_location_select" aria-label="{{ $fieldname }}"{{ (isset($multiple) && ($multiple=='true')) ? " multiple='multiple'" : '' }}{!! ((isset($item)) && (Helper::checkIfRequired($item, $fieldname))) ? ' required ' : '' !!}{!! (!empty($company_id)) ? ' data-company-id="'.e($company_id).'"' : '' !!}>
<select class="js-data-ajax" data-endpoint="locations" data-placeholder="{{ trans('general.select_location') }}" name="{{ $fieldname }}" style="width: 100%" id="{{ $fieldname }}_location_select" aria-label="{{ $fieldname }}"{{ (isset($multiple) && ($multiple=='true')) ? " multiple='multiple'" : '' }}{!! ((isset($item)) && (Helper::checkIfRequired($item, $fieldname))) ? ' required ' : '' !!}>
@isset($selected)
@foreach($selected as $location_id)
<option value="{{ $location_id }}" selected="selected" role="option" aria-selected="true" role="option">
@@ -3,7 +3,7 @@
<label for="{{ $fieldname }}" class="col-md-3 control-label">{{ $translated_name }}</label>
<div class="col-md-7">
<select class="js-data-ajax" data-endpoint="users" data-placeholder="{{ trans('general.select_user') }}" name="{{ $fieldname }}" style="width: 100%" id="assigned_user_select" aria-label="{{ $fieldname }}"{{ ((isset($required)) && ($required=='true')) ? ' required' : '' }}{!! (!empty($company_id)) ? ' data-company-ids="'.e($company_id).'"' : '' !!}>
<select class="js-data-ajax" data-endpoint="users" data-placeholder="{{ trans('general.select_user') }}" name="{{ $fieldname }}" style="width: 100%" id="assigned_user_select" aria-label="{{ $fieldname }}"{{ ((isset($required)) && ($required=='true')) ? ' required' : '' }}>
@if ($user_id = old($fieldname, (isset($item)) ? $item->{$fieldname} : ''))
<option value="{{ $user_id }}" selected="selected" role="option" aria-selected="true" role="option">
{{ (\App\Models\User::find($user_id)) ? \App\Models\User::find($user_id)->present()->fullName : '' }}
+2 -2
View File
@@ -89,7 +89,7 @@
<div class="box-body">
<div class="col-md-3" id="included_fields_wrapper">
<div class="col-md-4" id="included_fields_wrapper">
<label class="form-control">
<input type="checkbox" id="checkAll" checked="checked">
@@ -344,7 +344,7 @@
@endif
</div> <!-- /.col-md-4-->
<div class="col-md-9">
<div class="col-md-8">
<p>
{!! trans('general.report_fields_info') !!}
+3 -3
View File
@@ -64,13 +64,13 @@
<!-- Company -->
@if (\App\Models\Company::canManageUsersCompanies())
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.select_company'), 'fieldname' => 'company_ids', 'multiple' => 'true'])
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.select_company'), 'fieldname' => 'company_id'])
<div class="form-group">
<div class=" col-md-9 col-md-offset-3">
<label class="form-control">
<input type="checkbox" name="null_company_ids" value="1" />
{{ trans_choice('general.set_users_field_to_null', count($users), ['field' => trans('general.companies'), 'user_count' => count($users)]) }}
<input type="checkbox" name="null_company_id" value="1" />
{{ trans_choice('general.set_users_field_to_null', count($users), ['field' => trans('general.company'), 'user_count' => count($users)]) }}
</label>
</div>
</div>
+3 -11
View File
@@ -345,22 +345,14 @@
<!-- Company -->
@if ((Gate::allows('canEditAuthFields', $user)) && (\App\Models\Company::canManageUsersCompanies()))
@include ('partials.forms.edit.company-select', [
'translated_name' => trans('general.company'),
'fieldname' => 'company_ids',
'multiple' => 'true',
'selected' => old('company_ids', $user->companies->isNotEmpty() ? $user->companies->pluck('id')->toArray() : ($user->company_id ? [$user->company_id] : [])),
])
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.company'), 'fieldname' => 'company_id'])
@else
@if ($user->companies->isNotEmpty())
@if ($user->company)
<div class="form-group">
<label class="col-md-3 control-label" for="locale">{{ trans('general.company') }}</label>
<div class="col-md-6">
<p class="form-control-static">
@foreach ($user->companies as $company)
<span class="label label-light">{!! $company->present()->formattedNameLink !!}</span>
@endforeach
{{ $user->company ? $user->company->name : '' }}
</p>
</div>
</div>
-14
View File
@@ -726,20 +726,6 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
]
)->name('api.licenses.history')->withTrashed();
Route::post('{license_id}/checkout',
[
Api\LicensesController::class,
'checkout',
]
)->name('api.licenses.checkout');
Route::post('{license_id}/checkin',
[
Api\LicensesController::class,
'checkin',
]
)->name('api.licenses.checkin');
});
Route::resource('licenses',
+1 -1
View File
@@ -655,7 +655,7 @@ Route::group(['middleware' => 'web'], function () {
Route::post(
'two-factor',
[LoginController::class, 'postTwoFactorAuth']
)->middleware('throttle:two_factor');
);
Route::post(
'password/email',
+1 -1
View File
@@ -19,7 +19,7 @@ Route::group(['prefix' => 'licenses', 'middleware' => ['auth']], function () {
Route::post(
'{licenseId}/checkout/{seatId?}',
[Licenses\LicenseCheckoutController::class, 'store']
)->name('licenses.checkout.save');
); // name() would duplicate here, so we skip it.
Route::get('{licenseSeat}/checkin/{backto?}', [Licenses\LicenseCheckinController::class, 'create'])
->name('licenses.checkin')
@@ -81,33 +81,4 @@ class IndexAccessoryCheckoutsTest extends TestCase implements TestsFullMultipleC
->assertJsonPath('rows.0.assigned_to.id', $userB->id)
->assertJsonPath('rows.1.assigned_to.id', $userC->id);
}
public function test_checkout_search_by_company_name_returns_matching_users()
{
$company = Company::factory()->create(['name' => 'Jedi Order']);
$jedi = User::factory()->create();
$company->users()->attach($jedi);
$sith = User::factory()->create();
$accessory = Accessory::factory()->checkedOutToUsers([$jedi, $sith])->create();
$this->actingAsForApi(User::factory()->viewAccessories()->create())
->getJson(route('api.accessories.checkedout', ['accessory' => $accessory->id, 'search' => 'Jedi Order']))
->assertOk()
->assertJsonPath('total', 1)
->assertJsonPath('rows.0.assigned_to.id', $jedi->id);
}
public function test_checkout_search_by_company_name_does_not_return_users_in_other_companies()
{
Company::factory()->create(['name' => 'Jedi Order']);
$sith = User::factory()->create();
$accessory = Accessory::factory()->checkedOutToUsers([$sith])->create();
$this->actingAsForApi(User::factory()->viewAccessories()->create())
->getJson(route('api.accessories.checkedout', ['accessory' => $accessory->id, 'search' => 'Jedi Order']))
->assertOk()
->assertJsonPath('total', 0);
}
}
@@ -1,81 +0,0 @@
<?php
namespace Tests\Feature\Assets\Api;
use App\Models\Asset;
use App\Models\User;
use Tests\TestCase;
class AssetBySerialTest extends TestCase
{
public function test_returns_asset_by_serial()
{
$asset = Asset::factory()->create(['serial' => 'TEST-API-SERIAL-123']);
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.assets.show.byserial', ['any' => 'TEST-API-SERIAL-123']))
->assertOk()
->assertJsonFragment(['serial' => 'TEST-API-SERIAL-123'])
->assertJsonStructure(['total', 'rows']);
}
public function test_returns_multiple_assets_with_same_serial()
{
Asset::factory()->count(3)->create(['serial' => 'DUPE-SERIAL']);
$response = $this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.assets.show.byserial', ['any' => 'DUPE-SERIAL']))
->assertOk();
$this->assertEquals(3, $response->json('total'));
}
public function test_returns_error_when_serial_not_found()
{
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.assets.show.byserial', ['any' => 'DOES-NOT-EXIST']))
->assertOk()
->assertJson(['status' => 'error']);
}
public function test_requires_permission()
{
Asset::factory()->create(['serial' => 'TEST-API-SERIAL-AUTH']);
$this->actingAsForApi(User::factory()->create())
->getJson(route('api.assets.show.byserial', ['any' => 'TEST-API-SERIAL-AUTH']))
->assertForbidden();
}
public function test_does_not_return_deleted_assets_by_default()
{
$asset = Asset::factory()->create(['serial' => 'DELETED-SERIAL']);
$asset->delete();
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.assets.show.byserial', ['any' => 'DELETED-SERIAL']))
->assertOk()
->assertJson(['status' => 'error']);
}
public function test_returns_deleted_assets_when_requested()
{
$asset = Asset::factory()->create(['serial' => 'DELETED-SERIAL-2']);
$asset->delete();
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.assets.show.byserial', ['any' => 'DELETED-SERIAL-2']).'?deleted=true')
->assertOk()
->assertJsonFragment(['serial' => 'DELETED-SERIAL-2']);
}
public function test_serial_with_slashes_works_in_path()
{
$asset = Asset::factory()->create(['serial' => 'SN/WITH/SLASHES']);
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.assets.show.byserial', ['any' => 'SN/WITH/SLASHES']))
->assertOk()
->assertJsonFragment(['serial' => 'SN/WITH/SLASHES']);
}
}
@@ -77,23 +77,4 @@ class AssetsForSelectListTest extends TestCase
->assertResponseDoesNotContainInResults($assetA)
->assertResponseContainsInResults($assetB);
}
public function test_assets_are_filtered_by_multiple_comma_separated_company_ids_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB, $companyC] = Company::factory()->count(3)->create();
$assetA = Asset::factory()->for($companyA)->create(['asset_tag' => 'A001']);
$assetB = Asset::factory()->for($companyB)->create(['asset_tag' => 'B001']);
$assetC = Asset::factory()->for($companyC)->create(['asset_tag' => 'C001']);
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->getJson(route('assets.selectlist', ['companyId' => $companyA->id.','.$companyB->id]))
->assertResponseContainsInResults($assetA)
->assertResponseContainsInResults($assetB)
->assertResponseDoesNotContainInResults($assetC);
}
}
@@ -1,48 +0,0 @@
<?php
namespace Tests\Feature\Assets\Ui;
use App\Models\Asset;
use App\Models\User;
use Tests\TestCase;
class AssetBySerialTest extends TestCase
{
public function test_redirects_to_asset_when_serial_in_path()
{
$asset = Asset::factory()->create(['serial' => 'TEST-SERIAL-123']);
$user = User::factory()->viewAssets()->create();
$this->actingAs($user)
->get(route('findbyserial/hardware', ['any' => 'TEST-SERIAL-123']))
->assertRedirectToRoute('hardware.show', $asset->id);
}
public function test_redirects_to_asset_when_serial_in_query_string()
{
$asset = Asset::factory()->create(['serial' => 'TEST-SERIAL-456']);
$user = User::factory()->viewAssets()->create();
$this->actingAs($user)
->get(route('findbyserial/hardware').'?serial=TEST-SERIAL-456')
->assertRedirectToRoute('hardware.show', $asset->id);
}
public function test_redirects_to_index_when_serial_not_found()
{
$user = User::factory()->viewAssets()->create();
$this->actingAs($user)
->get(route('findbyserial/hardware', ['any' => 'DOES-NOT-EXIST']))
->assertRedirectToRoute('hardware.index');
}
public function test_requires_permission()
{
Asset::factory()->create(['serial' => 'TEST-SERIAL-789']);
$this->actingAs(User::factory()->create())
->get(route('findbyserial/hardware', ['any' => 'TEST-SERIAL-789']))
->assertForbidden();
}
}
@@ -1,31 +0,0 @@
<?php
namespace Tests\Feature\Authentication;
use App\Models\User;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TwoFactorRateLimitTest extends TestCase
{
#[Test]
public function post_two_factor_is_rate_limited(): void
{
config(['auth.two_factor.max_attempts_per_min' => 3]);
$user = User::factory()->create([
'two_factor_secret' => 'JBSWY3DPEHPK3PXP',
'two_factor_enrolled' => 1,
]);
$this->actingAs($user);
for ($i = 0; $i < 3; $i++) {
$this->post('/two-factor', ['two_factor_secret' => '000000'])
->assertRedirect();
}
$this->post('/two-factor', ['two_factor_secret' => '000000'])
->assertStatus(429);
}
}
@@ -228,51 +228,4 @@ class AccessoryCheckoutTest extends TestCase implements TestsPermissionsRequirem
$this->assertEquals(1, $accessoryInCompanyA->fresh()->numRemaining());
}
public function test_user_in_same_company_can_checkout_accessory_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
$company = Company::factory()->create();
$accessory = Accessory::factory()->for($company)->create(['qty' => 5]);
$target = $company->users()->save(User::factory()->make());
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->postJson(route('api.accessories.checkout', $accessory), [
'assigned_user' => $target->id,
'checkout_to_type' => 'user',
])
->assertOk()
->assertStatusMessageIs('success');
}
public function test_user_in_multiple_companies_can_checkout_accessory_from_any_of_their_companies_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$target = User::factory()->create();
$target->companies()->sync([$companyA->id, $companyB->id]);
$accessoryInA = Accessory::factory()->for($companyA)->create(['qty' => 5]);
$accessoryInB = Accessory::factory()->for($companyB)->create(['qty' => 5]);
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->postJson(route('api.accessories.checkout', $accessoryInA), [
'assigned_user' => $target->id,
'checkout_to_type' => 'user',
])
->assertOk()
->assertStatusMessageIs('success');
$this->actingAsForApi($actor)
->postJson(route('api.accessories.checkout', $accessoryInB), [
'assigned_user' => $target->id,
'checkout_to_type' => 'user',
])
->assertOk()
->assertStatusMessageIs('success');
}
}
@@ -152,48 +152,4 @@ class ConsumableCheckoutTest extends TestCase
$this->assertEquals(1, $consumableInCompanyA->fresh()->numRemaining());
}
public function test_user_in_same_company_can_checkout_consumable_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
$company = Company::factory()->create();
$consumable = Consumable::factory()->for($company)->create(['qty' => 5]);
$target = $company->users()->save(User::factory()->make());
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $target->id,
])
->assertOk()
->assertStatusMessageIs('success');
}
public function test_user_in_multiple_companies_can_checkout_consumable_from_any_of_their_companies_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$target = User::factory()->create();
$target->companies()->sync([$companyA->id, $companyB->id]);
$consumableInA = Consumable::factory()->for($companyA)->create(['qty' => 5]);
$consumableInB = Consumable::factory()->for($companyB)->create(['qty' => 5]);
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumableInA), [
'assigned_to' => $target->id,
])
->assertOk()
->assertStatusMessageIs('success');
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumableInB), [
'assigned_to' => $target->id,
])
->assertOk()
->assertStatusMessageIs('success');
}
}
@@ -2,16 +2,10 @@
namespace Tests\Feature\Importing\Api;
use App\Models\Import;
use App\Models\User;
use PHPUnit\Framework\Attributes\Test;
use Tests\Support\Importing\CleansUpImportFiles;
use Tests\Support\Importing\UsersImportFileBuilder;
class GeneralImportTest extends ImportDataTestCase
{
use CleansUpImportFiles;
public function test_requires_existing_import()
{
$this->actingAsForApi(User::factory()->canImport()->create());
@@ -19,21 +13,4 @@ class GeneralImportTest extends ImportDataTestCase
$this->importFileResponse(['import' => 9999, 'import-type' => 'accessory'])
->assertStatusMessageIs('import-errors');
}
#[Test]
public function processing_another_users_import_does_not_overwrite_created_by(): void
{
$originalOwner = User::factory()->superuser()->create();
$otherUser = User::factory()->superuser()->create();
$import = Import::factory()->users()->create([
'file_path' => UsersImportFileBuilder::new()->saveToImportsDirectory(),
'created_by' => $originalOwner->id,
]);
$this->actingAsForApi($otherUser);
$this->importFileResponse(['import' => $import->id, 'import-type' => 'user'])->assertOk();
$this->assertEquals($originalOwner->id, $import->refresh()->created_by);
}
}
@@ -1,111 +0,0 @@
<?php
namespace Tests\Feature\Importing\Api;
use App\Models\Company;
use App\Models\Import;
use App\Models\User;
use Illuminate\Testing\TestResponse;
use Tests\Support\Importing\CleansUpImportFiles;
use Tests\Support\Importing\UsersImportFileBuilder as ImportFileBuilder;
class ImportUsersMultiCompanyTest extends ImportDataTestCase
{
use CleansUpImportFiles;
protected function importFileResponse(array $parameters = []): TestResponse
{
if (! array_key_exists('import-type', $parameters)) {
$parameters['import-type'] = 'user';
}
return parent::importFileResponse($parameters);
}
public function test_pipe_separated_company_names_create_multiple_pivot_entries()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$importFileBuilder = ImportFileBuilder::new([
'companyName' => $companyA->name.'|'.$companyB->name,
]);
$row = $importFileBuilder->firstRow();
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
$this->actingAsForApi(User::factory()->superuser()->create());
$this->importFileResponse(['import' => $import->id])
->assertOk();
$user = User::where('username', $row['username'])->firstOrFail();
$this->assertCount(2, $user->companies, 'User should belong to both pipe-separated companies');
$this->assertTrue($user->companies->contains($companyA));
$this->assertTrue($user->companies->contains($companyB));
}
public function test_pipe_separated_companies_create_new_companies_when_not_found()
{
$importFileBuilder = ImportFileBuilder::new([
'companyName' => 'Acme Corp|Widget Inc',
]);
$row = $importFileBuilder->firstRow();
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
$this->actingAsForApi(User::factory()->superuser()->create());
$this->importFileResponse(['import' => $import->id])
->assertOk();
$user = User::where('username', $row['username'])->firstOrFail();
$this->assertCount(2, $user->companies, 'User should belong to two newly-created companies');
$names = $user->companies->pluck('name')->all();
$this->assertContains('Acme Corp', $names);
$this->assertContains('Widget Inc', $names);
}
public function test_single_company_name_without_pipe_works_as_before()
{
$company = Company::factory()->create();
$importFileBuilder = ImportFileBuilder::new([
'companyName' => $company->name,
]);
$row = $importFileBuilder->firstRow();
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
$this->actingAsForApi(User::factory()->superuser()->create());
$this->importFileResponse(['import' => $import->id])
->assertOk();
$user = User::where('username', $row['username'])->firstOrFail();
$this->assertCount(1, $user->companies);
$this->assertTrue($user->companies->contains($company));
}
public function test_blank_company_column_leaves_user_without_companies()
{
$importFileBuilder = ImportFileBuilder::new([
'companyName' => '',
]);
$row = $importFileBuilder->firstRow();
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
$this->actingAsForApi(User::factory()->superuser()->create());
$this->importFileResponse(['import' => $import->id])
->assertOk();
$user = User::where('username', $row['username'])->firstOrFail();
$this->assertCount(0, $user->companies, 'Blank company column should leave user with no companies');
}
}
@@ -69,7 +69,7 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
]);
$newUser = User::query()
->with(['companies', 'location'])
->with(['company', 'location'])
->where('username', $row['username'])
->sole();
@@ -80,7 +80,7 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
$this->assertEquals($row['lastName'], $newUser->last_name);
$this->assertEquals($row['displayName'], $newUser->display_name);
$this->assertEquals($row['employeeNumber'], $newUser->employee_num);
$this->assertEquals($row['companyName'], $newUser->companies->first()->name);
$this->assertEquals($row['companyName'], $newUser->company->name);
$this->assertEquals($row['location'], $newUser->location->name);
$this->assertEquals($row['phoneNumber'], $newUser->phone);
$this->assertEquals($row['position'], $newUser->jobtitle);
@@ -229,14 +229,16 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
$this->actingAsForApi(User::factory()->superuser()->create());
$this->importFileResponse(['import' => $import->id, 'import-update' => true])->assertOk();
$updatedUser = User::query()->with(['companies', 'location'])->find($user->id);
$updatedUser = User::query()->with(['company', 'location'])->find($user->id);
$updatedAttributes = [
'first_name',
'display_name',
'email',
'last_name',
'employee_num',
'company',
'location_id',
'company_id',
'updated_at',
'phone',
'jobtitle',
@@ -247,7 +249,7 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
$this->assertEquals($row['displayName'], $updatedUser->display_name);
$this->assertEquals($row['lastName'], $updatedUser->last_name);
$this->assertEquals($row['employeeNumber'], $updatedUser->employee_num);
$this->assertEquals($row['companyName'], $updatedUser->companies->first()->name);
$this->assertEquals($row['companyName'], $updatedUser->company->name);
$this->assertEquals($row['location'], $updatedUser->location->name);
$this->assertEquals($row['phoneNumber'], $updatedUser->phone);
$this->assertEquals($row['position'], $updatedUser->jobtitle);
@@ -344,7 +346,7 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
->json();
$newUser = User::query()
->with(['companies', 'location'])
->with(['company', 'location'])
->where('username', $row['companyName'])
->sole();
@@ -354,7 +356,7 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
$this->assertEquals($row['dumbName'], $newUser->display_name);
$this->assertEquals($row['email'], $newUser->jobtitle);
$this->assertEquals($row['phoneNumber'], $newUser->employee_num);
$this->assertEquals($row['username'], $newUser->companies->first()->name);
$this->assertEquals($row['username'], $newUser->company->name);
$this->assertEquals($row['firstName'], $newUser->location->name);
$this->assertEquals($row['employeeNumber'], $newUser->phone);
$this->assertFalse(Hash::isHashed($newUser->password));
@@ -390,48 +392,4 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
$this->assertNull($newUser->reset_password_code);
$this->assertEquals(0, $newUser->activated);
}
#[Test]
public function import_only_user_cannot_overwrite_auth_fields_when_updating(): void
{
$victim = User::factory()->create([
'username' => 'victim_user',
'email' => 'original@example.com',
]);
$importFileBuilder = new ImportFileBuilder([
array_merge(ImportFileBuilder::new()->definition(), [
'username' => 'victim_user',
'email' => 'hijacked@evil.com',
]),
]);
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
$this->actingAsForApi(User::factory()->canImport()->create());
$this->importFileResponse(['import' => $import->id, 'import-update' => true])->assertOk();
$this->assertEquals('original@example.com', $victim->refresh()->email);
}
#[Test]
public function user_with_import_and_edit_users_permission_can_update_auth_fields(): void
{
$target = User::factory()->create([
'username' => 'target_user',
'email' => 'original@example.com',
]);
$importFileBuilder = new ImportFileBuilder([
array_merge(ImportFileBuilder::new()->definition(), [
'username' => 'target_user',
'email' => 'updated@example.com',
]),
]);
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
$this->actingAsForApi(User::factory()->canImport()->editUsers()->create());
$this->importFileResponse(['import' => $import->id, 'import-update' => true])->assertOk();
$this->assertEquals('updated@example.com', $target->refresh()->email);
}
}
@@ -485,50 +485,6 @@ class LicenseSeatUpdateTest extends TestCase
]);
}
public function test_user_in_same_company_can_be_assigned_license_seat_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
$company = Company::factory()->create();
$license = License::factory()->for($company)->create();
$seat = LicenseSeat::factory()->create(['license_id' => $license->id, 'assigned_to' => null, 'asset_id' => null]);
$target = $company->users()->save(User::factory()->make());
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->patchJson($this->route($seat), ['assigned_to' => $target->id])
->assertOk()
->assertStatusMessageIs('success');
$this->assertEquals($target->id, $seat->fresh()->assigned_to);
}
public function test_user_in_multiple_companies_can_be_assigned_license_from_any_of_their_companies_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$target = User::factory()->create();
$target->companies()->sync([$companyA->id, $companyB->id]);
$actor = User::factory()->superuser()->create();
$licenseInA = License::factory()->for($companyA)->create();
$seatInA = LicenseSeat::factory()->create(['license_id' => $licenseInA->id, 'assigned_to' => null, 'asset_id' => null]);
$licenseInB = License::factory()->for($companyB)->create();
$seatInB = LicenseSeat::factory()->create(['license_id' => $licenseInB->id, 'assigned_to' => null, 'asset_id' => null]);
$this->actingAsForApi($actor)
->patchJson($this->route($seatInA), ['assigned_to' => $target->id])
->assertOk()
->assertStatusMessageIs('success');
$this->actingAsForApi($actor)
->patchJson($this->route($seatInB), ['assigned_to' => $target->id])
->assertOk()
->assertStatusMessageIs('success');
}
private function route(LicenseSeat $licenseSeat)
{
return route('api.licenses.seats.update', [$licenseSeat->license->id, $licenseSeat->id]);
@@ -1,318 +0,0 @@
<?php
namespace Tests\Feature\Licenses\Api;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class LicenseCheckoutCheckinTest extends TestCase
{
// ---------------------------------------------------------------------------
// Checkout
// ---------------------------------------------------------------------------
#[Test]
public function checkout_requires_checkout_permission(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => User::factory()->create()->id,
])
->assertForbidden();
}
#[Test]
public function checkout_to_user_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$target = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $target->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$seat = $license->licenseseats()->first();
$this->assertEquals($target->id, $seat->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_to_asset_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$asset = Asset::factory()->create();
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'asset',
'asset_id' => $asset->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$seat = $license->licenseseats()->first();
$this->assertEquals($asset->id, $seat->asset_id);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_to_specific_seat_by_id(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 3]);
$seats = $license->licenseseats()->orderBy('id')->get();
$target = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'seat_id' => $seats[1]->id,
'target_type' => 'user',
'assigned_to' => $target->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertEquals($target->id, $seats[1]->fresh()->assigned_to);
$this->assertNull($seats[0]->fresh()->assigned_to);
$this->assertNull($seats[2]->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_fails_when_no_seats_available(): void
{
$license = License::factory()->create(['seats' => 1]);
LicenseSeat::where('license_id', $license->id)->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => User::factory()->create()->id,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkout_returns_error_for_nonexistent_user(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => 99999,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkout_returns_error_for_nonexistent_asset(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'asset',
'asset_id' => 99999,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function sequential_checkouts_each_receive_a_distinct_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 2]);
$actor = User::factory()->checkoutLicenses()->create();
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $user1->id,
])
->assertJson(['status' => 'success']);
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $user2->id,
])
->assertJson(['status' => 'success']);
$assignedTo = $license->licenseseats()->pluck('assigned_to');
$this->assertCount(2, $assignedTo->filter());
$this->assertEquals(2, $assignedTo->unique()->count());
Event::assertDispatched(CheckoutableCheckedOut::class, 2);
}
// ---------------------------------------------------------------------------
// Checkin
// ---------------------------------------------------------------------------
#[Test]
public function checkin_requires_checkin_permission(): void
{
$license = License::factory()->create(['seats' => 1]);
$seat = $license->licenseseats()->first();
$seat->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertForbidden();
}
#[Test]
public function checkin_clears_assigned_user(): void
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => true]);
$user = User::factory()->create();
$seat = $license->licenseseats()->first();
$seat->update(['assigned_to' => $user->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->assigned_to);
$this->assertFalse((bool) $seat->fresh()->unreassignable_seat);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
#[Test]
public function checkin_clears_assigned_asset(): void
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => true]);
$asset = Asset::factory()->create();
$seat = $license->licenseseats()->first();
$seat->update(['asset_id' => $asset->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->asset_id);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
#[Test]
public function checkin_marks_seat_unreassignable_when_license_is_not_reassignable(): void
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => false]);
$user = User::factory()->create();
$seat = $license->licenseseats()->first();
$seat->update(['assigned_to' => $user->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->assigned_to);
$this->assertTrue((bool) $seat->fresh()->unreassignable_seat);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
#[Test]
public function checkin_returns_error_for_unassigned_seat(): void
{
$license = License::factory()->create(['seats' => 1]);
$seat = $license->licenseseats()->first();
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkin_returns_error_for_seat_not_belonging_to_license(): void
{
$license1 = License::factory()->create(['seats' => 1]);
$license2 = License::factory()->create(['seats' => 1]);
$seat2 = $license2->licenseseats()->first();
$seat2->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license1->id), [
'seat_id' => $seat2->id,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkout_then_checkin_frees_the_seat(): void
{
Event::fake([CheckoutableCheckedOut::class, CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => true]);
$user = User::factory()->create();
$actor = User::factory()->checkoutLicenses()->checkinLicenses()->create();
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $user->id,
])
->assertJson(['status' => 'success']);
$seat = $license->licenseseats()->first();
$this->assertEquals($user->id, $seat->fresh()->assigned_to);
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
}
@@ -1,155 +0,0 @@
<?php
namespace Tests\Feature\Licenses\Ui;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class LicenseCheckoutTest extends TestCase
{
#[Test]
public function requires_checkout_permission(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAs(User::factory()->create())
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => User::factory()->create()->id,
])
->assertForbidden();
}
#[Test]
public function checkout_to_user_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$target = User::factory()->create();
$seat = $license->licenseseats()->first();
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => $target->id,
])
->assertRedirect()
->assertSessionHas('success');
$this->assertEquals($target->id, $seat->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_to_asset_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$asset = Asset::factory()->create();
$seat = $license->licenseseats()->first();
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', $license->id), [
'asset_id' => $asset->id,
])
->assertRedirect()
->assertSessionHas('success');
$this->assertEquals($asset->id, $seat->fresh()->asset_id);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_of_specific_seat_by_id(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 3]);
$seats = $license->licenseseats()->orderBy('id')->get();
$target = User::factory()->create();
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', ['licenseId' => $license->id, 'seatId' => $seats[1]->id]), [
'assigned_to' => $target->id,
])
->assertRedirect()
->assertSessionHas('success');
$this->assertEquals($target->id, $seats[1]->fresh()->assigned_to);
$this->assertNull($seats[0]->fresh()->assigned_to);
$this->assertNull($seats[2]->fresh()->assigned_to);
}
#[Test]
public function cannot_checkout_when_no_seats_available(): void
{
$license = License::factory()->create(['seats' => 1]);
LicenseSeat::where('license_id', $license->id)->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => User::factory()->create()->id,
])
->assertRedirect()
->assertSessionHas('error');
}
#[Test]
public function sequential_checkouts_each_receive_a_distinct_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 2]);
$actor = User::factory()->checkoutLicenses()->create();
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), ['assigned_to' => $user1->id])
->assertSessionHas('success');
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), ['assigned_to' => $user2->id])
->assertSessionHas('success');
$assignedTo = $license->licenseseats()->pluck('assigned_to');
$this->assertCount(2, $assignedTo->filter());
$this->assertContains($user1->id, $assignedTo);
$this->assertContains($user2->id, $assignedTo);
$this->assertEquals(2, $assignedTo->unique()->count(), 'Both users should hold different seats');
Event::assertDispatched(CheckoutableCheckedOut::class, 2);
}
#[Test]
public function third_checkout_fails_when_only_two_seats_exist(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 2]);
$actor = User::factory()->checkoutLicenses()->create();
foreach ([User::factory()->create(), User::factory()->create()] as $user) {
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), ['assigned_to' => $user->id])
->assertSessionHas('success');
}
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => User::factory()->create()->id,
])
->assertRedirect()
->assertSessionHas('error');
$this->assertEquals(0, $license->fresh()->freeSeats()->count());
Event::assertDispatched(CheckoutableCheckedOut::class, 2);
}
}
@@ -1,156 +0,0 @@
<?php
namespace Tests\Feature\Users\Api;
use App\Models\Company;
use App\Models\User;
use Tests\TestCase;
class UserCompanyMembershipTest extends TestCase
{
public function test_store_with_company_ids_syncs_pivot()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$actor = User::factory()->superuser()->create();
$response = $this->actingAsForApi($actor)
->postJson(route('api.users.store'), [
'first_name' => 'Jane',
'last_name' => 'Doe',
'username' => 'janedoe_pivot_test',
'password' => 'secret123456',
'password_confirmation' => 'secret123456',
'company_ids' => [$companyA->id, $companyB->id],
])
->assertOk()
->assertStatusMessageIs('success');
$user = User::where('username', 'janedoe_pivot_test')->firstOrFail();
$this->assertCount(2, $user->companies, 'User should belong to two companies via pivot');
$this->assertTrue($user->companies->contains($companyA));
$this->assertTrue($user->companies->contains($companyB));
}
public function test_update_with_company_ids_syncs_pivot()
{
[$companyA, $companyB, $companyC] = Company::factory()->count(3)->create();
$user = User::factory()->create(['company_id' => $companyA->id]);
$user->companies()->sync([$companyA->id]);
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->patchJson(route('api.users.update', $user), [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'username' => $user->username,
'company_ids' => [$companyB->id, $companyC->id],
])
->assertOk()
->assertStatusMessageIs('success');
$user->refresh();
$this->assertCount(2, $user->companies, 'Pivot should be updated to two companies');
$this->assertFalse($user->companies->contains($companyA), 'Old company should be removed');
$this->assertTrue($user->companies->contains($companyB));
$this->assertTrue($user->companies->contains($companyC));
}
public function test_api_response_includes_companies_array()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$user = User::factory()->create(['company_id' => $companyA->id]);
$user->companies()->sync([$companyA->id, $companyB->id]);
$actor = User::factory()->superuser()->create();
$response = $this->actingAsForApi($actor)
->getJson(route('api.users.show', $user))
->assertOk();
$companies = $response->json('companies');
$this->assertIsArray($companies);
$this->assertCount(2, $companies, 'Response should include both companies');
$returnedIds = collect($companies)->pluck('id')->all();
$this->assertContains($companyA->id, $returnedIds);
$this->assertContains($companyB->id, $returnedIds);
}
public function test_api_response_company_entries_include_tag_color()
{
$company = Company::factory()->create(['tag_color' => '#ff0000']);
$user = User::factory()->create(['company_id' => $company->id]);
$user->companies()->sync([$company->id]);
$actor = User::factory()->superuser()->create();
$response = $this->actingAsForApi($actor)
->getJson(route('api.users.show', $user))
->assertOk();
$companies = $response->json('companies');
$this->assertEquals('#ff0000', $companies[0]['tag_color']);
}
public function test_multi_company_user_can_see_users_from_all_their_companies_when_fmcs_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB, $companyC] = Company::factory()->count(3)->create();
$userInA = User::factory()->create(['first_name' => 'Alice', 'last_name' => 'Alpha', 'company_id' => $companyA->id]);
$companyA->users()->syncWithoutDetaching([$userInA->id]);
$userInB = User::factory()->create(['first_name' => 'Bob', 'last_name' => 'Beta', 'company_id' => $companyB->id]);
$companyB->users()->syncWithoutDetaching([$userInB->id]);
$userInC = User::factory()->create(['first_name' => 'Carol', 'last_name' => 'Gamma', 'company_id' => $companyC->id]);
$companyC->users()->syncWithoutDetaching([$userInC->id]);
// Acting user belongs to both A and B.
$actor = User::factory()->viewUsers()->create(['company_id' => null]);
$actor->companies()->sync([$companyA->id, $companyB->id]);
$response = $this->actingAsForApi($actor)
->getJson(route('api.users.index'))
->assertOk();
$names = collect($response->json('rows'))->pluck('name');
$this->assertTrue($names->contains(fn ($n) => str_contains($n, 'Alice')), 'Should see company A user');
$this->assertTrue($names->contains(fn ($n) => str_contains($n, 'Bob')), 'Should see company B user');
$this->assertFalse($names->contains(fn ($n) => str_contains($n, 'Carol')), 'Should NOT see company C user');
}
public function test_user_with_no_companies_sees_only_unassigned_users_when_fmcs_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
$company = Company::factory()->create();
$assignedUser = User::factory()->create(['company_id' => $company->id]);
$company->users()->syncWithoutDetaching([$assignedUser->id]);
$unassignedUser = User::factory()->create(['company_id' => null]);
// Actor belongs to no companies.
$actor = User::factory()->viewUsers()->create(['company_id' => null]);
$response = $this->actingAsForApi($actor)
->getJson(route('api.users.index'))
->assertOk();
$ids = collect($response->json('rows'))->pluck('id');
$this->assertFalse($ids->contains($assignedUser->id), 'Should not see user assigned to a company');
$this->assertTrue($ids->contains($unassignedUser->id), 'Should see user with no company');
$this->assertTrue($ids->contains($actor->id), 'Should see self');
}
}
@@ -114,54 +114,4 @@ class UsersForSelectListTest extends TestCase
$response = $this->getJson(route('api.users.selectlist', ['search' => 'dvader']))->assertOk();
$this->assertEquals(0, collect($response->json('results'))->count());
}
public function test_users_are_filtered_by_company_id_parameter_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$userInA = User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Skywalker', 'username' => 'lskywalker_fmcs1']);
$companyA->users()->attach($userInA);
$userInB = User::factory()->create(['first_name' => 'Darth', 'last_name' => 'Vader', 'username' => 'dvader_fmcs1']);
$companyB->users()->attach($userInB);
$actor = User::factory()->superuser()->create();
$response = $this->actingAsForApi($actor)
->getJson(route('api.users.selectlist', ['companyId' => $companyA->id]))
->assertOk();
$results = collect($response->json('results'));
$this->assertTrue($results->pluck('text')->contains(fn ($t) => str_contains($t, 'Luke')));
$this->assertFalse($results->pluck('text')->contains(fn ($t) => str_contains($t, 'Darth')));
}
public function test_users_are_filtered_by_multiple_comma_separated_company_ids_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB, $companyC] = Company::factory()->count(3)->create();
$userInA = User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Skywalker', 'username' => 'lskywalker_fmcs2']);
$companyA->users()->attach($userInA);
$userInB = User::factory()->create(['first_name' => 'Obi-Wan', 'last_name' => 'Kenobi', 'username' => 'okenobi_fmcs2']);
$companyB->users()->attach($userInB);
$userInC = User::factory()->create(['first_name' => 'Darth', 'last_name' => 'Vader', 'username' => 'dvader_fmcs2']);
$companyC->users()->attach($userInC);
$actor = User::factory()->superuser()->create();
$response = $this->actingAsForApi($actor)
->getJson(route('api.users.selectlist', ['companyId' => $companyA->id.','.$companyB->id]))
->assertOk();
$results = collect($response->json('results'));
$this->assertTrue($results->pluck('text')->contains(fn ($t) => str_contains($t, 'Luke')));
$this->assertTrue($results->pluck('text')->contains(fn ($t) => str_contains($t, 'Obi-Wan')));
$this->assertFalse($results->pluck('text')->contains(fn ($t) => str_contains($t, 'Darth')));
}
}
-17
View File
@@ -2,7 +2,6 @@
namespace Tests\Feature\Users\Ui;
use App\Models\Company;
use App\Models\User;
use Tests\TestCase;
@@ -14,20 +13,4 @@ class CloneUserTest extends TestCase
->get(route('users.clone.show', User::factory()->create()))
->assertOk();
}
public function test_clone_prepopulates_all_companies_for_multi_company_user()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$user = User::factory()->create(['company_id' => $companyA->id]);
$user->companies()->sync([$companyA->id, $companyB->id]);
$response = $this->actingAs(User::factory()->superuser()->create())
->get(route('users.clone.show', $user))
->assertOk();
// Both company IDs should be pre-selected in the form.
$response->assertSee('value="'.$companyA->id.'"', false);
$response->assertSee('value="'.$companyB->id.'"', false);
}
}
@@ -4,7 +4,6 @@ namespace Tests\Feature\Users\Ui;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\Group;
use App\Models\LicenseSeat;
@@ -131,24 +130,4 @@ class ExportUsersTest extends TestCase
trans('general.end_date') => '2030-12-31',
]);
}
public function test_multi_company_user_exports_pipe_separated_company_names()
{
[$companyA, $companyB] = Company::factory()->sequence(
['name' => 'Rebel Alliance'],
['name' => 'Galactic Senate'],
)->count(2)->create();
$user = User::factory()->create(['company_id' => $companyA->id]);
$user->companies()->sync([$companyA->id, $companyB->id]);
$this->actingAs(User::factory()->viewUsers()->create())
->get(route('users.export'))
->assertOk()
->assertCsvHeader()
->assertSeePairsInStreamedResponse([
trans('admin/users/table.first_name') => $user->first_name,
trans('admin/companies/table.title') => 'Rebel Alliance|Galactic Senate',
]);
}
}
@@ -337,10 +337,6 @@ class UpdateUserTest extends TestCase
'id' => $id,
'first_name' => 'test',
'username' => 'test',
]);
$this->assertDatabaseHas('company_user', [
'user_id' => $id,
'company_id' => $companyB->id,
]);
}
@@ -1,88 +0,0 @@
<?php
namespace Tests\Feature\Users\Ui;
use App\Models\Company;
use App\Models\User;
use Tests\TestCase;
class UserCompanyMembershipTest extends TestCase
{
public function test_updating_user_via_ui_syncs_company_pivot()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$user = User::factory()->create(['company_id' => $companyA->id]);
$user->companies()->sync([$companyA->id]);
$actor = User::factory()->superuser()->create();
$this->actingAs($actor)
->put(route('users.update', $user), [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'username' => $user->username,
'activated' => $user->activated,
'company_ids' => [$companyA->id, $companyB->id],
])
->assertRedirect();
$user->refresh();
$this->assertCount(2, $user->companies, 'Pivot should hold both companies after UI update');
$this->assertTrue($user->companies->contains($companyA));
$this->assertTrue($user->companies->contains($companyB));
}
public function test_bulk_edit_assigns_companies_via_pivot()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$users = User::factory()->count(3)->create(['company_id' => null]);
$actor = User::factory()->superuser()->create();
$ids = $users->pluck('id')->mapWithKeys(fn ($id) => [$id => $id])->all();
$this->actingAs($actor)
->post(route('users/bulkeditsave'), array_merge(
['ids' => $ids],
['company_ids' => [$companyA->id, $companyB->id]],
))
->assertRedirect();
foreach ($users as $user) {
$user->refresh();
$this->assertCount(2, $user->companies, "User {$user->id} should belong to two companies after bulk edit");
$this->assertTrue($user->companies->contains($companyA));
$this->assertTrue($user->companies->contains($companyB));
}
}
public function test_bulk_edit_clears_company_pivot_when_null_flag_set()
{
$company = Company::factory()->create();
$users = User::factory()->count(2)->create(['company_id' => $company->id]);
foreach ($users as $user) {
$user->companies()->sync([$company->id]);
}
$actor = User::factory()->superuser()->create();
$ids = $users->pluck('id')->mapWithKeys(fn ($id) => [$id => $id])->all();
$this->actingAs($actor)
->post(route('users/bulkeditsave'), [
'ids' => $ids,
'null_company_ids' => '1',
])
->assertRedirect();
foreach ($users as $user) {
$user->refresh();
$this->assertCount(0, $user->companies, "User {$user->id} should have no companies after null flag");
$this->assertNull($user->company_id);
}
}
}
@@ -1,151 +0,0 @@
<?php
namespace Tests\Feature\Users;
use App\Models\Actionlog;
use App\Models\Company;
use App\Models\User;
use Tests\TestCase;
class UserCompanyLoggingTest extends TestCase
{
public function test_field_and_company_changes_produce_single_log_entry()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$user = User::factory()->create(['company_id' => $companyA->id, 'jobtitle' => 'Engineer']);
$user->companies()->sync([$companyA->id]);
$actor = User::factory()->superuser()->create();
$existingLogIds = Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->pluck('id');
$this->actingAsForApi($actor)
->patchJson(route('api.users.update', $user), [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'username' => $user->username,
'jobtitle' => 'Senior Engineer',
'company_ids' => [$companyB->id],
])
->assertOk();
$newLogs = Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->where('action_type', 'update')
->whereNotIn('id', $existingLogIds)
->get();
$this->assertCount(1, $newLogs, 'Field and company changes should produce exactly one log entry');
$meta = json_decode($newLogs->first()->log_meta, true);
$this->assertArrayHasKey('jobtitle', $meta, 'Log should include field change');
$this->assertArrayHasKey('companies', $meta, 'Log should include company change in same entry');
}
public function test_company_only_change_produces_standalone_log_entry()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$user = User::factory()->create(['company_id' => $companyA->id]);
$user->companies()->sync([$companyA->id]);
$actor = User::factory()->superuser()->create();
$existingLogIds = Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->pluck('id');
// Patch with no field changes — only company_ids differs.
$this->actingAsForApi($actor)
->patchJson(route('api.users.update', $user), [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'username' => $user->username,
'company_ids' => [$companyB->id],
])
->assertOk();
$newLogs = Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->where('action_type', 'update')
->whereNotIn('id', $existingLogIds)
->get();
$this->assertCount(1, $newLogs, 'Company-only change should produce one log entry');
$meta = json_decode($newLogs->first()->log_meta, true);
$this->assertArrayHasKey('companies', $meta, 'Log should record company change');
}
public function test_log_entry_records_old_and_new_company_ids()
{
[$companyA, $companyB, $companyC] = Company::factory()->count(3)->create();
$user = User::factory()->create(['company_id' => $companyA->id]);
$user->companies()->sync([$companyA->id, $companyB->id]);
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->patchJson(route('api.users.update', $user), [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'username' => $user->username,
'company_ids' => [$companyC->id],
])
->assertOk();
$log = Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->where('action_type', 'update')
->latest('id')
->first();
$meta = json_decode($log->log_meta, true);
$this->assertEqualsCanonicalizing(
[$companyA->id, $companyB->id],
$meta['companies']['old'],
'Log old company IDs should match previous pivot'
);
$this->assertEqualsCanonicalizing(
[$companyC->id],
$meta['companies']['new'],
'Log new company IDs should match updated pivot'
);
}
public function test_no_change_to_companies_does_not_create_extra_log_entry()
{
$company = Company::factory()->create();
$user = User::factory()->create(['company_id' => $company->id]);
$user->companies()->sync([$company->id]);
$actor = User::factory()->superuser()->create();
$existingLogIds = Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->pluck('id');
// Send the same company_ids — no field changes either.
$this->actingAsForApi($actor)
->patchJson(route('api.users.update', $user), [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'username' => $user->username,
'company_ids' => [$company->id],
])
->assertOk();
$newLogs = Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->whereNotIn('id', $existingLogIds)
->count();
$this->assertEquals(0, $newLogs, 'No changes should produce no new log entries');
}
}