Compare commits
134 Commits
v8.6.0
...
security-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
| c25d56ea85 | |||
| f92a9a6cc6 | |||
| 988729fbeb | |||
| e00f7b5b67 | |||
| 39fbe98313 | |||
| 46d5234fd7 | |||
| dd4117bd5b | |||
| 4dcd5190df | |||
| 48728e83b2 | |||
| 087b895bba | |||
| 2ed28f7f7a | |||
| 9f50328da2 | |||
| 3737b34913 | |||
| aa0eb24e80 | |||
| 9d012dd06d | |||
| df28c80553 | |||
| 2a3a3f7818 | |||
| 15cb7993f6 | |||
| 15529a0c9c | |||
| d2c30dd08c | |||
| 972b27140a | |||
| cac13dd949 | |||
| 112bf498e6 | |||
| 02488a62c1 | |||
| f5313f6ec0 | |||
| 3206549170 | |||
| 59b621500f | |||
| cd5716d66d | |||
| 6a68a38d71 | |||
| f23ea5ce8f | |||
| c893b69b5f | |||
| 269e6c4ef6 | |||
| a0ab9d3a80 | |||
| cdd72cf372 | |||
| e38b8cdd68 | |||
| c44cb23dea | |||
| 84bdfa98d1 | |||
| f3055e7442 | |||
| 9c36ade1e2 | |||
| 4127c6a0c0 | |||
| c133c869ae | |||
| d74197aacc | |||
| c870dd0dae | |||
| 6d1d89105d | |||
| f3a4f5edaa | |||
| 8f61d1e729 | |||
| 4782734ed4 | |||
| a9d65f7e81 | |||
| e59f5d92a4 | |||
| 93576fc435 | |||
| 221ae337f2 | |||
| 1b1d1f77d5 | |||
| d7ef85235c | |||
| 3a714c3ef6 | |||
| 2a69bf903e | |||
| 23c93473c8 | |||
| 266f04b04c | |||
| 9f64a90a45 | |||
| baacf171f4 | |||
| 109e7fff68 | |||
| 816868cfc8 | |||
| c21b44aded | |||
| 0565ec22cb | |||
| 17fc52a237 | |||
| f535b8ffd2 | |||
| 221e495974 | |||
| 8f06902230 | |||
| ff95416a90 | |||
| b1491b524d | |||
| 703c5ca4ed | |||
| ce6c7146ea | |||
| e1e614ebc8 | |||
| 7918653413 | |||
| a23bc89607 | |||
| 6f25f80260 | |||
| 6da5f2e19b | |||
| 518351eba1 | |||
| ce0ce8688b | |||
| 43be1e8364 | |||
| 6e749d34a4 | |||
| 6e55d78c19 | |||
| 884dc926fe | |||
| a383033ffa | |||
| 67fa473281 | |||
| 28b3e34a84 | |||
| 72383fdbd7 | |||
| 44f9101d93 | |||
| 9cab197651 | |||
| db4fcff1f3 | |||
| ea820ce99a | |||
| d21ff001bf | |||
| 69ddde697a | |||
| 3f72d0afd8 | |||
| 1d209155f2 | |||
| 20b2d22991 | |||
| e12ac03dd8 | |||
| 9c73b26cd1 | |||
| cabc842f52 | |||
| d099cbd8e5 | |||
| 33846b0d61 | |||
| b7df1dcefb | |||
| e7c80b89eb | |||
| 50ba979840 | |||
| 6fd834e4d2 | |||
| 6ae09e15fb | |||
| f03b27ec88 | |||
| cc1e0d82dd | |||
| f233bd2d01 | |||
| 7a8b22df26 | |||
| 17df4a08a7 | |||
| c377b41198 | |||
| e9e9dfeeab | |||
| f8c084cde7 | |||
| 8f7fa6c0f5 | |||
| f381362130 | |||
| bef4a50720 | |||
| 2a93de675f | |||
| e5f41f8f17 | |||
| b9da8ee55c | |||
| bf525f7213 | |||
| c9ef163142 | |||
| feb3bd58cf | |||
| f9288e450b | |||
| 541128dd7a | |||
| 23b9c881ad | |||
| cacd6f7e9b | |||
| 4db4314f18 | |||
| 51aa66a77d | |||
| aa0b491080 | |||
| c01c9201ee | |||
| 0ad1a5b6ba | |||
| 95909d552a | |||
| a159c3b84e | |||
| 19f70656ee |
@@ -4271,6 +4271,15 @@
|
||||
"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
@@ -113,7 +113,7 @@ ENABLE_HSTS=false
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_PREFIX=snipeit
|
||||
|
||||
# --------------------------------------------
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ ENABLE_HSTS=false
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_PREFIX=snipeit
|
||||
|
||||
# --------------------------------------------
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ CORS_ALLOWED_ORIGINS="*"
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: LOGIN THROTTLING
|
||||
|
||||
+2
-1
@@ -142,7 +142,7 @@ ENABLE_HSTS=false
|
||||
# OPTIONAL: CACHE SETTINGS
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_PREFIX=snipeit
|
||||
|
||||
# --------------------------------------------
|
||||
@@ -210,6 +210,7 @@ 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
@@ -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/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") |
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
@@ -56,6 +56,7 @@ 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 \
|
||||
|
||||
@@ -98,6 +98,7 @@ 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,6 +234,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -303,7 +307,7 @@ class AccessoriesController extends Controller
|
||||
$this->authorize('checkout', $accessory);
|
||||
$target = $this->determineCheckoutTarget();
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $target->companies()->where('companies.id', $accessory->company_id)->exists())) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
|
||||
@@ -603,8 +603,11 @@ class AssetsController extends Controller
|
||||
])->with('model', 'status', 'assignedTo')
|
||||
->NotArchived();
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
|
||||
$assets->where('assets.company_id', $request->input('companyId'));
|
||||
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 ($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') && ($consumable->company_id !== $user->company_id)) {
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $user->companies()->where('companies.id', $consumable->company_id)->exists())) {
|
||||
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.company', 'asset.company')
|
||||
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.companies', 'asset.company')
|
||||
->where('license_seats.license_id', $licenseId);
|
||||
|
||||
if ($request->input('status') == 'available') {
|
||||
@@ -132,91 +132,110 @@ class LicenseSeatsController extends Controller
|
||||
|
||||
$this->authorize('checkout', License::class);
|
||||
|
||||
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])->find($seatId);
|
||||
$errorResponse = null;
|
||||
$updatedSeat = null;
|
||||
|
||||
if (! $licenseSeat) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
|
||||
}
|
||||
// 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);
|
||||
|
||||
$license = $licenseSeat->license;
|
||||
if (! $license || $license->id != intval($licenseId)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
|
||||
}
|
||||
if (! $licenseSeat) {
|
||||
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat 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) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
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')));
|
||||
}
|
||||
}
|
||||
$license = $licenseSeat->license;
|
||||
if (! $license || $license->id != intval($licenseId)) {
|
||||
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
|
||||
|
||||
$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'));
|
||||
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')));
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$oldUser = $licenseSeat->user;
|
||||
$oldAsset = $licenseSeat->asset;
|
||||
$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'));
|
||||
|
||||
// attempt to update the license seat
|
||||
$licenseSeat->fill($validated);
|
||||
if (! $targetAsset) {
|
||||
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
|
||||
|
||||
// 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();
|
||||
return;
|
||||
}
|
||||
|
||||
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')));
|
||||
}
|
||||
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')));
|
||||
|
||||
// 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'));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
return false;
|
||||
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($assignmentTouched) {
|
||||
@@ -225,25 +244,29 @@ class LicenseSeatsController extends Controller
|
||||
$licenseSeat->unreassignable_seat = true;
|
||||
|
||||
if (! $licenseSeat->save()) {
|
||||
return false;
|
||||
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
$updatedSeat = $licenseSeat;
|
||||
});
|
||||
|
||||
if ($updated) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
|
||||
if ($errorResponse) {
|
||||
return $errorResponse;
|
||||
}
|
||||
|
||||
return Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
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;
|
||||
@@ -261,6 +267,167 @@ 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,6 +427,10 @@ 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');
|
||||
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -51,7 +52,6 @@ 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('company')
|
||||
->with('companies')
|
||||
->with('department')
|
||||
->with('createdBy')
|
||||
->withCount([
|
||||
@@ -191,7 +191,7 @@ class UsersController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$users = $users->where('users.company_id', '=', $request->input('company_id'));
|
||||
$users = $users->whereHas('companies', fn ($q) => $q->where('companies.id', $request->input('company_id')));
|
||||
}
|
||||
|
||||
if ($request->filled('phone')) {
|
||||
@@ -396,6 +396,13 @@ 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'))
|
||||
@@ -443,7 +450,6 @@ 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')) {
|
||||
@@ -488,6 +494,12 @@ 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')));
|
||||
}
|
||||
|
||||
@@ -577,10 +589,6 @@ 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'));
|
||||
}
|
||||
@@ -609,6 +617,14 @@ 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,11 +567,12 @@ class AssetsController extends Controller
|
||||
*
|
||||
* @since [v3.0]
|
||||
*/
|
||||
public function getAssetBySerial(Request $request): RedirectResponse
|
||||
public function getAssetBySerial(Request $request, $serial = null): RedirectResponse
|
||||
{
|
||||
$serial = $serial ?: $request->input('serial');
|
||||
$topsearch = ($request->input('topsearch') == 'true');
|
||||
|
||||
if (! $asset = Asset::where('serial', '=', $request->input('serial'))->first()) {
|
||||
if (! $asset = Asset::where('serial', '=', $serial)->first()) {
|
||||
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
}
|
||||
$this->authorize('view', $asset);
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -94,23 +95,31 @@ class LicenseCheckoutController extends Controller
|
||||
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
|
||||
}
|
||||
|
||||
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId);
|
||||
$licenseSeat->created_by = auth()->id();
|
||||
$licenseSeat->notes = $request->input('notes');
|
||||
$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);
|
||||
}
|
||||
});
|
||||
|
||||
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'),
|
||||
@@ -156,9 +165,11 @@ class LicenseCheckoutController extends Controller
|
||||
return redirect()->route('licenses.index')->with('error', trans('Something went wrong handling this checkout.'));
|
||||
}
|
||||
|
||||
protected function findLicenseSeatToCheckout($license, $seatId)
|
||||
protected function findLicenseSeatToCheckout($license, $seatId, bool $lock = false)
|
||||
{
|
||||
$licenseSeat = LicenseSeat::find($seatId) ?? $license->freeSeat();
|
||||
$licenseSeat = $seatId
|
||||
? LicenseSeat::where('id', $seatId)->when($lock, fn ($q) => $q->lockForUpdate())->first()
|
||||
: $license->freeSeat(lock: $lock);
|
||||
|
||||
if (! $licenseSeat) {
|
||||
if ($seatId) {
|
||||
|
||||
@@ -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('users', $location->users()->with('companies')->get())
|
||||
->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('users', $location->users()->with('companies')->get())
|
||||
->with('location', $location)
|
||||
->with('consumables', $location->consumables)
|
||||
->with('components', $location->components)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -168,7 +169,6 @@ 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_id') == '1') {
|
||||
if ($request->input('null_company_ids') == '1') {
|
||||
$this->update_array['company_id'] = null;
|
||||
}
|
||||
|
||||
@@ -233,6 +233,22 @@ 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) {
|
||||
@@ -473,6 +489,12 @@ 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);
|
||||
$user->company_id = Company::getIdForUser($request->input('company_id', null));
|
||||
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
|
||||
$user->manager_id = $request->input('manager_id', null);
|
||||
$user->notes = $request->input('notes');
|
||||
$user->address = $request->input('address', null);
|
||||
@@ -153,6 +153,7 @@ class UsersController extends Controller
|
||||
}
|
||||
|
||||
if ($user->save()) {
|
||||
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
|
||||
|
||||
if (($user->activated == '1') && ($user->email != '') && ($request->input('send_welcome') == '1')) {
|
||||
|
||||
@@ -275,7 +276,7 @@ class UsersController extends Controller
|
||||
$user->phone = $request->input('phone');
|
||||
$user->mobile = $request->input('mobile');
|
||||
$user->location_id = $request->input('location_id', null);
|
||||
$user->company_id = Company::getIdForUser($request->input('company_id', null));
|
||||
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
|
||||
$user->manager_id = $request->input('manager_id', null);
|
||||
$user->notes = $request->input('notes');
|
||||
$user->department_id = $request->input('department_id', null);
|
||||
@@ -336,6 +337,8 @@ 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'));
|
||||
@@ -480,7 +483,7 @@ class UsersController extends Controller
|
||||
$permissions = $request->input('permissions', []);
|
||||
app('request')->request->set('permissions', $permissions);
|
||||
|
||||
$user_to_clone = User::with('userloc')->withTrashed()->find($user->id);
|
||||
$user_to_clone = User::with('userloc', 'companies')->withTrashed()->find($user->id);
|
||||
// Make sure they can view this particular user
|
||||
$this->authorize('view', $user_to_clone);
|
||||
|
||||
@@ -598,7 +601,7 @@ class UsersController extends Controller
|
||||
'manager',
|
||||
'groups',
|
||||
'userloc',
|
||||
'company',
|
||||
'companies',
|
||||
'createdBy'
|
||||
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
|
||||
->orderBy('created_at', 'DESC')
|
||||
@@ -620,7 +623,7 @@ class UsersController extends Controller
|
||||
// Add a new row with data
|
||||
$values = [
|
||||
$user->id,
|
||||
($user->company) ? $user->company->name : '',
|
||||
$user->companies->pluck('name')->implode('|'),
|
||||
$user->jobtitle,
|
||||
$user->employee_num,
|
||||
$user->first_name,
|
||||
|
||||
@@ -121,6 +121,7 @@ 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
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -84,6 +85,7 @@ class Kernel extends HttpKernel
|
||||
'auth:api',
|
||||
CheckLocale::class,
|
||||
LogAuthedUserHeader::class,
|
||||
SetPaginationDefaults::class,
|
||||
SubstituteBindings::class,
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -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 = auth()->id();
|
||||
$import->created_by = $import->created_by ?? auth()->id();
|
||||
$import->save();
|
||||
$fieldMappings = [];
|
||||
|
||||
|
||||
@@ -293,6 +293,28 @@ 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,13 +38,11 @@ class LicenseSeatsTransformer
|
||||
'tag_color' => $seat->user->department->tag_color ? e($seat->user->department->tag_color) : null,
|
||||
|
||||
] : null,
|
||||
'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,
|
||||
'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(),
|
||||
'created_at' => Helper::getFormattedDateObject($seat->created_at, 'datetime'),
|
||||
] : null,
|
||||
'assigned_asset' => ($seat->asset) ? [
|
||||
|
||||
@@ -82,11 +82,17 @@ class UsersTransformer
|
||||
'consumables_count' => (int) $user->consumables_count,
|
||||
'manages_users_count' => (int) $user->manages_users_count,
|
||||
'manages_locations_count' => (int) $user->manages_locations_count,
|
||||
'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,
|
||||
// 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,
|
||||
] : 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),
|
||||
@@ -144,6 +150,11 @@ 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),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Importer;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Company;
|
||||
use App\Models\Department;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
@@ -35,6 +36,31 @@ 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.
|
||||
*
|
||||
@@ -80,6 +106,13 @@ 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);
|
||||
@@ -104,11 +137,13 @@ class UserImporter extends ItemImporter
|
||||
|
||||
$this->log('Updating User');
|
||||
|
||||
if (Auth::check() && (! Gate::allows('canEditAuthFields', $user))) {
|
||||
unset($user->username);
|
||||
unset($user->email);
|
||||
unset($user->password);
|
||||
unset($user->activated);
|
||||
// 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']);
|
||||
}
|
||||
|
||||
$user->update($this->sanitizeItemForUpdating($user));
|
||||
@@ -116,6 +151,11 @@ 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)
|
||||
@@ -125,6 +165,17 @@ 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;
|
||||
@@ -140,6 +191,13 @@ 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) {
|
||||
|
||||
@@ -146,7 +146,8 @@ 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);
|
||||
->orWhere('note', 'like', $search_str)
|
||||
->orWhereHas('companies', fn ($q) => $q->where('companies.name', 'like', $search_str));
|
||||
}
|
||||
}
|
||||
)->select('id');
|
||||
|
||||
+106
-24
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -94,7 +95,26 @@ final class Company extends SnipeModel
|
||||
'notes',
|
||||
];
|
||||
|
||||
private static function isFullMultipleCompanySupportEnabled()
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
@@ -179,20 +199,65 @@ final class Company extends SnipeModel
|
||||
}
|
||||
|
||||
if (auth()->user()) {
|
||||
// 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;
|
||||
if (auth()->user()->isSuperUser()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ($current_user_company_id == null) || ($current_user_company_id == $companyable_company_id) || auth()->user()->isSuperUser();
|
||||
$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 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()
|
||||
@@ -202,8 +267,9 @@ final class Company extends SnipeModel
|
||||
|
||||
public static function canManageUsersCompanies()
|
||||
{
|
||||
return ! self::isFullMultipleCompanySupportEnabled() || auth()->user()->isSuperUser() ||
|
||||
auth()->user()->company_id == null;
|
||||
return ! self::isFullMultipleCompanySupportEnabled()
|
||||
|| auth()->user()->isSuperUser()
|
||||
|| empty(self::getCurrentUserCompanyIds());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +308,7 @@ final class Company extends SnipeModel
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->hasMany(User::class, 'company_id');
|
||||
return $this->belongsToMany(User::class, 'company_user');
|
||||
}
|
||||
|
||||
public function assets()
|
||||
@@ -304,27 +370,43 @@ final class Company extends SnipeModel
|
||||
*/
|
||||
private static function scopeCompanyablesDirectly($query, $column = 'company_id', $table_name = null)
|
||||
{
|
||||
|
||||
$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;
|
||||
}
|
||||
$companyIds = self::getCurrentUserCompanyIds();
|
||||
|
||||
// If we are scoping the companies table itself, look for the company.id
|
||||
if ($query->getModel()->getTable() == 'companies') {
|
||||
return $query->where('companies.id', '=', $company_id);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// If the column exists in the table, use it to scope the query
|
||||
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
|
||||
if ($query && $query->getModel() && Schema::hasColumn($query->getModel()->getTable(), $column)) {
|
||||
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
|
||||
|
||||
return $query->where($table.$column, '=', $company_id);
|
||||
}
|
||||
if (empty($companyIds)) {
|
||||
return $query->whereNull($table.$column);
|
||||
}
|
||||
|
||||
return $query->whereIn($table.$column, $companyIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -803,7 +803,7 @@ class License extends Depreciable
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function freeSeat()
|
||||
public function freeSeat(bool $lock = false)
|
||||
{
|
||||
return $this->licenseseats()
|
||||
->whereNull('deleted_at')
|
||||
@@ -813,6 +813,7 @@ class License extends Depreciable
|
||||
->whereNull('asset_id');
|
||||
})
|
||||
->orderBy('id', 'asc')
|
||||
->when($lock, fn ($q) => $q->lockForUpdate())
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
+75
-3
@@ -18,6 +18,7 @@ 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;
|
||||
@@ -59,6 +60,13 @@ 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',
|
||||
@@ -166,7 +174,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
'userloc' => ['name', 'address', 'address2', 'city', 'state', 'zip'],
|
||||
'department' => ['name'],
|
||||
'groups' => ['name'],
|
||||
'company' => ['name'],
|
||||
'companies' => ['name'],
|
||||
'manager' => ['first_name', 'last_name', 'username', 'display_name'],
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
@@ -244,6 +252,15 @@ 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();
|
||||
@@ -603,6 +620,51 @@ 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
|
||||
*
|
||||
@@ -725,9 +787,10 @@ 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(\App\Models\License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
|
||||
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1338,7 +1401,14 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
*/
|
||||
public function scopeOrderCompany($query, $order)
|
||||
{
|
||||
return $query->leftJoin('companies as companies_user', 'users.company_id', '=', 'companies_user.id')->orderBy('companies_user.name', $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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1393,6 +1463,7 @@ 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)
|
||||
@@ -1434,6 +1505,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
])
|
||||
->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all direct and indirect subordinates for this user.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,8 @@ 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',
|
||||
@@ -31,7 +33,6 @@ class UserObserver
|
||||
'employee_num',
|
||||
'username',
|
||||
'notes',
|
||||
'company_id',
|
||||
'ldap_import',
|
||||
'locale',
|
||||
'two_factor_enrolled',
|
||||
@@ -58,18 +59,44 @@ class UserObserver
|
||||
// Make sure the info is in the allow fields array
|
||||
if (in_array($key, $allowed_fields)) {
|
||||
|
||||
// Check and see if the value changed
|
||||
if ($user->getRawOriginal()[$key] != $user->getAttributes()[$key]) {
|
||||
$oldValue = $user->getRawOriginal()[$key];
|
||||
$newValue = $user->getAttributes()[$key];
|
||||
|
||||
$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'] = '*************';
|
||||
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;
|
||||
}
|
||||
// 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'] = '*************';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,12 +106,16 @@ class UserObserver
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = User::class;
|
||||
$logAction->item_id = $user->id;
|
||||
$logAction->target_type = User::class; // can we instead say $logAction->item = $asset ?
|
||||
$logAction->target_type = User::class;
|
||||
$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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -218,6 +218,15 @@ 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,
|
||||
|
||||
@@ -280,13 +280,13 @@ class LicensePresenter extends Presenter
|
||||
'formatter' => 'emailFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'assigned_user.company',
|
||||
'field' => 'assigned_user.companies',
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.company'),
|
||||
'title' => trans('general.companies'),
|
||||
'visible' => true,
|
||||
'formatter' => 'companiesLinkObjFormatter',
|
||||
'formatter' => 'companiesArrayLinkFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'assigned_user.department',
|
||||
|
||||
@@ -83,13 +83,13 @@ class UserPresenter extends Presenter
|
||||
'formatter' => 'usersLinkFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'company',
|
||||
'field' => 'companies',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'sortable' => false,
|
||||
'switchable' => true,
|
||||
'title' => trans('admin/companies/table.title'),
|
||||
'title' => trans('general.companies'),
|
||||
'visible' => false,
|
||||
'formatter' => 'companiesLinkObjFormatter',
|
||||
'formatter' => 'companiesArrayLinkFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'employee_num',
|
||||
|
||||
@@ -103,5 +103,11 @@ 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());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,43 +32,6 @@ 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)
|
||||
|
||||
@@ -353,10 +353,15 @@ 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') {
|
||||
$company_id = array_get($validator->getData(), 'company_id');
|
||||
$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]
|
||||
)));
|
||||
$location = Location::find($value);
|
||||
|
||||
if (($location) && ($company_id != $location->company_id)) {
|
||||
if ($location && ! in_array($location->company_id, $companyIds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-curl": "*",
|
||||
"ext-exif": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
|
||||
@@ -122,6 +122,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@@ -33,27 +33,18 @@ class UserSeeder extends Seeder
|
||||
|
||||
$departmentIds = Department::all()->pluck('id');
|
||||
|
||||
User::factory()->count(1)->firstAdmin()
|
||||
->state(new Sequence(fn ($sequence) => [
|
||||
'company_id' => $companyIds->random(),
|
||||
// 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,
|
||||
'department_id' => $departmentIds->random(),
|
||||
]))
|
||||
->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();
|
||||
]);
|
||||
$ids = $companyIds->random(min(rand(2, 3), $companyIds->count()))->toArray();
|
||||
User::where('id', $user->id)->update(['company_id' => $ids[0]]);
|
||||
$user->companies()->sync($ids);
|
||||
}
|
||||
|
||||
// Superusers — one company each.
|
||||
User::factory()->count(3)->superuser()
|
||||
->state(new Sequence(fn ($sequence) => [
|
||||
'company_id' => $companyIds->random(),
|
||||
@@ -61,6 +52,7 @@ class UserSeeder extends Seeder
|
||||
]))
|
||||
->create();
|
||||
|
||||
// Admins — one company each.
|
||||
User::factory()->count(3)->admin()
|
||||
->state(new Sequence(fn ($sequence) => [
|
||||
'company_id' => $companyIds->random(),
|
||||
@@ -68,13 +60,38 @@ class UserSeeder extends Seeder
|
||||
]))
|
||||
->create();
|
||||
|
||||
User::factory()->count(2000)->viewAssets()
|
||||
// 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()
|
||||
->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);
|
||||
|
||||
@@ -51,4 +51,4 @@ SECURE_COOKIES=false
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
+1
-1
@@ -60,4 +60,4 @@ SECURE_COOKIES=false
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
+1
-1
@@ -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_DRIVER" value="sync"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<ini name="display_errors" value="true"/>
|
||||
</php>
|
||||
|
||||
Vendored
+53227
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"/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",
|
||||
"/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",
|
||||
"/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",
|
||||
|
||||
@@ -210,7 +210,7 @@ $(function () {
|
||||
search: params.term,
|
||||
page: params.page || 1,
|
||||
statusType: link.data("asset-status-type"),
|
||||
companyId: link.data("company-id"),
|
||||
companyId: link.data("company-ids") || link.data("company-id"),
|
||||
};
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -85,6 +85,7 @@ return [
|
||||
'click_here' => 'Click here',
|
||||
'clear_selection' => 'Clear Selection',
|
||||
'companies' => 'Companies',
|
||||
'companies_var' => 'Company|Companies',
|
||||
'company' => 'Company',
|
||||
'component' => 'Component',
|
||||
'components' => 'Components',
|
||||
|
||||
@@ -219,14 +219,19 @@
|
||||
|
||||
|
||||
<!-- company -->
|
||||
@if (!is_null($user->company))
|
||||
@if ($user->companies->isNotEmpty())
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-3">
|
||||
{{ trans('general.company') }}
|
||||
{{ trans_choice('general.companies_var', $user->companies->count()) }}
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
{!! $user->company->present()->formattedNameLink !!}
|
||||
@foreach ($user->companies as $userCompany)
|
||||
<span class="label label-light">{!! $userCompany->present()->formattedNameLink !!}</span>
|
||||
@if (!$loop->last)
|
||||
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -221,10 +221,30 @@
|
||||
@endif
|
||||
|
||||
|
||||
@if ($infoPanelObj->company)
|
||||
@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)
|
||||
<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,6 +9,7 @@
|
||||
'multiple' => false,
|
||||
'helpText' => null,
|
||||
'hideNewButton' => false,
|
||||
'companyId' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -41,6 +42,9 @@
|
||||
@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'])
|
||||
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.select_user'), 'fieldname' => 'assigned_to', 'required'=> 'true', 'company_id' => $consumable->company_id])
|
||||
|
||||
|
||||
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $consumable->getEula() || ($snipeSettings->webhook_endpoint!=''))
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
@endif
|
||||
|
||||
<!-- Locations -->
|
||||
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.location'), 'fieldname' => 'location_id'])
|
||||
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.location'), 'fieldname' => 'location_id', 'company_id' => $asset->company_id])
|
||||
|
||||
<!-- Update location -->
|
||||
<div class="form-group">
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
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 -->
|
||||
|
||||
@@ -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', 'style' => (session('checkout_to_type') ?: 'user') == 'user' ? '' : 'display: none;'])
|
||||
@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;'])
|
||||
<!-- 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', 'style' => session('checkout_to_type') == 'location' ? '' : '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;'])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
: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)))' }};
|
||||
@@ -69,6 +70,10 @@
|
||||
--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"] {
|
||||
@@ -84,7 +89,6 @@
|
||||
--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));
|
||||
@@ -109,7 +113,6 @@
|
||||
--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));
|
||||
@@ -579,6 +582,21 @@
|
||||
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,
|
||||
|
||||
@@ -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', '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;'])
|
||||
@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;'])
|
||||
|
||||
<!-- Note -->
|
||||
<div class="form-group {{ $errors->has('notes') ? 'error' : '' }}">
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<tr>
|
||||
<td>{{ $counter }}</td>
|
||||
<td>{{ (($user) && ($user->company)) ? $user->company->name : '' }}</td>
|
||||
<td>{{ ($user) ? $user->companies->pluck('name')->implode(', ') : '' }}</td>
|
||||
<td>{{ ($user) ? $user->first_name .' '. $user->last_name : '' }}</td>
|
||||
<td>{{ ($user) ? $user->employee_num : '' }}</td>
|
||||
<td>{{ (($user) && ($user->department)) ? $user->department->name : '' }}</td>
|
||||
|
||||
@@ -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->company))
|
||||
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->company->id }}' class="form-control">
|
||||
@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">
|
||||
@endif
|
||||
|
||||
<!-- Select company, only for users with multicompany access - replace default company -->
|
||||
|
||||
@@ -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->company)
|
||||
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->company->id }}' class="form-control">
|
||||
@if ($user->companies->isNotEmpty())
|
||||
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->companies->first()->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-default">' + value.rows[index].name + '</a> ';
|
||||
groups += '<a href="{{ config('app.url') }}/admin/groups/' + value.rows[index].id + '" class="label label-light">' + value.rows[index].name + '</a> ';
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -2178,6 +2178,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 }}" style="width: 100%"
|
||||
data-placeholder="{{ trans('general.select_company') }}" name="{{ $fieldname }}{{ (isset($multiple) && ($multiple=='true')) ? '[]' : '' }}" 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-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'" : '' }}>
|
||||
<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'" : '' }}>
|
||||
@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 ' : '' !!}>
|
||||
<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).'"' : '' !!}>
|
||||
@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' : '' }}>
|
||||
<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).'"' : '' !!}>
|
||||
@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 : '' }}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
<div class="box-body">
|
||||
|
||||
<div class="col-md-4" id="included_fields_wrapper">
|
||||
<div class="col-md-3" 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-8">
|
||||
<div class="col-md-9">
|
||||
|
||||
<p>
|
||||
{!! trans('general.report_fields_info') !!}
|
||||
|
||||
@@ -64,13 +64,13 @@
|
||||
|
||||
<!-- Company -->
|
||||
@if (\App\Models\Company::canManageUsersCompanies())
|
||||
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.select_company'), 'fieldname' => 'company_id'])
|
||||
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.select_company'), 'fieldname' => 'company_ids', 'multiple' => 'true'])
|
||||
|
||||
<div class="form-group">
|
||||
<div class=" col-md-9 col-md-offset-3">
|
||||
<label class="form-control">
|
||||
<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)]) }}
|
||||
<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)]) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -345,14 +345,22 @@
|
||||
|
||||
<!-- Company -->
|
||||
@if ((Gate::allows('canEditAuthFields', $user)) && (\App\Models\Company::canManageUsersCompanies()))
|
||||
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.company'), 'fieldname' => 'company_id'])
|
||||
@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] : [])),
|
||||
])
|
||||
@else
|
||||
@if ($user->company)
|
||||
@if ($user->companies->isNotEmpty())
|
||||
<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">
|
||||
{{ $user->company ? $user->company->name : '' }}
|
||||
@foreach ($user->companies as $company)
|
||||
<span class="label label-light">{!! $company->present()->formattedNameLink !!}</span>
|
||||
@endforeach
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -726,6 +726,20 @@ 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
@@ -655,7 +655,7 @@ Route::group(['middleware' => 'web'], function () {
|
||||
Route::post(
|
||||
'two-factor',
|
||||
[LoginController::class, 'postTwoFactorAuth']
|
||||
);
|
||||
)->middleware('throttle:two_factor');
|
||||
|
||||
Route::post(
|
||||
'password/email',
|
||||
|
||||
@@ -19,7 +19,7 @@ Route::group(['prefix' => 'licenses', 'middleware' => ['auth']], function () {
|
||||
Route::post(
|
||||
'{licenseId}/checkout/{seatId?}',
|
||||
[Licenses\LicenseCheckoutController::class, 'store']
|
||||
); // name() would duplicate here, so we skip it.
|
||||
)->name('licenses.checkout.save');
|
||||
|
||||
Route::get('{licenseSeat}/checkin/{backto?}', [Licenses\LicenseCheckinController::class, 'create'])
|
||||
->name('licenses.checkin')
|
||||
|
||||
@@ -81,4 +81,33 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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,4 +77,23 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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,4 +228,51 @@ 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,4 +152,48 @@ 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,10 +2,16 @@
|
||||
|
||||
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());
|
||||
@@ -13,4 +19,21 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?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(['company', 'location'])
|
||||
->with(['companies', '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->company->name);
|
||||
$this->assertEquals($row['companyName'], $newUser->companies->first()->name);
|
||||
$this->assertEquals($row['location'], $newUser->location->name);
|
||||
$this->assertEquals($row['phoneNumber'], $newUser->phone);
|
||||
$this->assertEquals($row['position'], $newUser->jobtitle);
|
||||
@@ -229,16 +229,14 @@ 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(['company', 'location'])->find($user->id);
|
||||
$updatedUser = User::query()->with(['companies', 'location'])->find($user->id);
|
||||
$updatedAttributes = [
|
||||
'first_name',
|
||||
'display_name',
|
||||
'email',
|
||||
'last_name',
|
||||
'employee_num',
|
||||
'company',
|
||||
'location_id',
|
||||
'company_id',
|
||||
'updated_at',
|
||||
'phone',
|
||||
'jobtitle',
|
||||
@@ -249,7 +247,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->company->name);
|
||||
$this->assertEquals($row['companyName'], $updatedUser->companies->first()->name);
|
||||
$this->assertEquals($row['location'], $updatedUser->location->name);
|
||||
$this->assertEquals($row['phoneNumber'], $updatedUser->phone);
|
||||
$this->assertEquals($row['position'], $updatedUser->jobtitle);
|
||||
@@ -346,7 +344,7 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
|
||||
->json();
|
||||
|
||||
$newUser = User::query()
|
||||
->with(['company', 'location'])
|
||||
->with(['companies', 'location'])
|
||||
->where('username', $row['companyName'])
|
||||
->sole();
|
||||
|
||||
@@ -356,7 +354,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->company->name);
|
||||
$this->assertEquals($row['username'], $newUser->companies->first()->name);
|
||||
$this->assertEquals($row['firstName'], $newUser->location->name);
|
||||
$this->assertEquals($row['employeeNumber'], $newUser->phone);
|
||||
$this->assertFalse(Hash::isHashed($newUser->password));
|
||||
@@ -392,4 +390,48 @@ 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,6 +485,50 @@ 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]);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?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,4 +114,54 @@ 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')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Feature\Users\Ui;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -13,4 +14,20 @@ 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,6 +4,7 @@ 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;
|
||||
@@ -130,4 +131,24 @@ 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,6 +337,10 @@ class UpdateUserTest extends TestCase
|
||||
'id' => $id,
|
||||
'first_name' => 'test',
|
||||
'username' => 'test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('company_user', [
|
||||
'user_id' => $id,
|
||||
'company_id' => $companyB->id,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user