Compare commits

...

134 Commits

Author SHA1 Message Date
snipe c25d56ea85 Refactored licenses controller to use a pessimistic lock inside a transaction 2026-05-26 14:24:02 +01:00
snipe f92a9a6cc6 Made isFullMultipleCompanySupportEnabled a public method 2026-05-26 14:23:32 +01:00
snipe 988729fbeb Skip user records if user exists in another company if FMCS is enabled 2026-05-26 13:36:02 +01:00
snipe e00f7b5b67 Added tests 2026-05-26 13:31:33 +01:00
snipe 39fbe98313 Fixed overwriting ownership of import 2026-05-26 13:11:30 +01:00
snipe 46d5234fd7 Throttle TOTP requests 2026-05-26 13:04:26 +01:00
snipe dd4117bd5b Tighter guard on user imports auth fields if the user is authenticated (aka not run via cli) 2026-05-26 12:56:10 +01:00
snipe 4dcd5190df Merge pull request #19025 from grokability/move-api-singletons-into-middleware
Move API singletons from SettingServiceProvider into middleware
2026-05-26 12:07:03 +01:00
snipe 48728e83b2 Merge pull request #19051 from grokability/_multi-company-support
Allow user to be a member of multiple companies
2026-05-26 12:03:24 +01:00
snipe 087b895bba Merge branch 'develop' into _multi-company-support
# Conflicts:
#	app/Http/Controllers/Users/BulkUsersController.php
#	app/Presenters/LicensePresenter.php
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-26 11:53:48 +01:00
snipe 2ed28f7f7a Dev manifest 2026-05-26 11:48:34 +01:00
snipe 9f50328da2 Merge hell :( 2026-05-26 11:48:15 +01:00
snipe 3737b34913 Back-patch security fixes 2026-05-26 11:36:29 +01:00
snipe aa0eb24e80 Fixed merge mess 2026-05-26 11:15:30 +01:00
snipe 9d012dd06d WTF 2026-05-26 11:14:27 +01:00
snipe df28c80553 Dev assets 2026-05-26 11:03:55 +01:00
snipe 2a3a3f7818 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:03:55 +01:00
snipe 15cb7993f6 Moved password visibility toggle to snipeit.js 2026-05-26 11:03:55 +01:00
snipe 15529a0c9c Bulk checkin license seats 2026-05-26 11:03:55 +01:00
snipe d2c30dd08c Dev assets 2026-05-26 11:03:55 +01:00
snipe 972b27140a Updated assets 2026-05-26 11:03:55 +01:00
snipe cac13dd949 Dev assets 2026-05-26 11:03:55 +01:00
snipe 112bf498e6 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:03:55 +01:00
snipe 02488a62c1 Updated controllers 2026-05-26 11:03:55 +01:00
snipe f5313f6ec0 Updated dev assets 2026-05-26 11:03:55 +01:00
snipe 3206549170 Moved password visibility toggle to snipeit.js 2026-05-26 11:03:48 +01:00
snipe 59b621500f Bulk checkin license seats 2026-05-26 11:03:40 +01:00
snipe cd5716d66d Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-26 11:03:07 +01:00
snipe 6a68a38d71 Dev assets 2026-05-26 11:02:44 +01:00
snipe f23ea5ce8f Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:02:35 +01:00
snipe c893b69b5f Fixed merge conflict 2026-05-26 10:52:04 +01:00
snipe 269e6c4ef6 Dev assets *again* 2026-05-26 10:49:32 +01:00
snipe a0ab9d3a80 Updated dev assets 2026-05-26 10:49:06 +01:00
snipe cdd72cf372 Dev assets 2026-05-26 10:49:05 +01:00
snipe e38b8cdd68 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 10:49:05 +01:00
snipe c44cb23dea Updated JS to add the array endpoint for company_ids (plural) 2026-05-26 10:49:05 +01:00
snipe 84bdfa98d1 Updated dev assets 2026-05-26 10:49:05 +01:00
snipe f3055e7442 Moved password visibility toggle to snipeit.js 2026-05-26 10:48:54 +01:00
snipe 9c36ade1e2 Bulk checkin license seats 2026-05-26 10:48:44 +01:00
snipe 4127c6a0c0 Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-26 10:48:24 +01:00
snipe c133c869ae Dev assets 2026-05-26 10:47:48 +01:00
snipe d74197aacc Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 10:47:40 +01:00
snipe c870dd0dae Updated assets 2026-05-26 10:41:48 +01:00
snipe 6d1d89105d Updated JS to add the array endpoint for company_ids (plural) 2026-05-26 10:41:30 +01:00
snipe f3a4f5edaa Allow query string or parameter for byserial 2026-05-26 10:41:23 +01:00
snipe 8f61d1e729 Add @CybotTM as a contributor 2026-05-26 10:41:23 +01:00
Sebastian Mendel 4782734ed4 Fix dead QUEUE_DRIVER env var name in templates and test config
`config/queue.php` reads `env('QUEUE_CONNECTION', 'sync')` since the
Laravel Shift in v6.0.0 (commit cc3c59bf97), but seven .env templates
and phpunit.xml still set `QUEUE_DRIVER` — the old Laravel <5.7 name
that the framework no longer reads. The default is `sync` anyway so
the gap is silent; but anyone copying these templates and trying to
enable an async driver (redis, database, beanstalkd, sqs) finds their
setting silently ignored.

Rename across:
- .env.example
- .env.docker
- .env.dev.docker
- .env.dusk.example
- docker/docker-secrets.env
- docker/docker.env
- phpunit.xml (XML <env> tag)

No code change. Default value `sync` preserved everywhere.

---
Disclosure: drafted with a coding agent's help.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
2026-05-26 10:41:23 +01:00
chrisnox a9d65f7e81 Update README.md 2026-05-26 10:41:22 +01:00
chrisnox e59f5d92a4 Update README.md 2026-05-26 10:41:22 +01:00
snipe 93576fc435 Include table prefixes on OAuth Clients 2026-05-26 10:41:22 +01:00
snipe 221ae337f2 Nullsafe on requesting user 2026-05-26 10:41:22 +01:00
snipe 1b1d1f77d5 Account for deleted adminuser in journal note for assets 2026-05-26 10:41:22 +01:00
snipe d7ef85235c Fixed flaky test 2026-05-26 10:41:22 +01:00
snipe 3a714c3ef6 Updated dev assets 2026-05-26 10:41:22 +01:00
snipe 2a69bf903e Fixed parenthases 2026-05-26 10:41:04 +01:00
snipe 23c93473c8 Moved password visibility toggle to snipeit.js 2026-05-26 10:41:03 +01:00
snipe 266f04b04c Fixed #19042 - use markdown for demo settings 2026-05-26 10:40:46 +01:00
snipe 9f64a90a45 Added newline 2026-05-26 10:40:46 +01:00
snipe baacf171f4 More pint compliance 2026-05-26 10:40:46 +01:00
snipe 109e7fff68 Bumped hash and improved the version console command 2026-05-26 10:40:46 +01:00
snipe 816868cfc8 Don’t show the serial field if the license does not have one 2026-05-26 10:40:46 +01:00
snipe c21b44aded Bumped hash and added pre-version 2026-05-26 10:40:46 +01:00
snipe 0565ec22cb Fixed #19057 - update last login on google auth 2026-05-26 10:40:46 +01:00
snipe 17fc52a237 Added to assets license tab as well 2026-05-26 10:40:46 +01:00
snipe f535b8ffd2 Bulk checkin license seats 2026-05-26 10:40:45 +01:00
snipe 221e495974 Show number of selected, use checkboxEnabledFormatter on simple toolbars 2026-05-26 10:40:45 +01:00
snipe 8f06902230 Use intended() for redirect back to where you were 2026-05-26 10:40:45 +01:00
snipe ff95416a90 Added bulk checkin controller method 2026-05-26 10:40:45 +01:00
snipe b1491b524d Added strings (to do: combine these maybe?) 2026-05-26 10:40:45 +01:00
snipe 703c5ca4ed Added checkin option to bulk asset menu 2026-05-26 10:40:45 +01:00
snipe ce6c7146ea Added blade 2026-05-26 10:40:45 +01:00
snipe e1e614ebc8 Added route 2026-05-26 10:40:45 +01:00
snipe 7918653413 Created test 2026-05-26 10:40:45 +01:00
snipe a23bc89607 Graceful redirect if the user is not allowed 2026-05-26 10:40:45 +01:00
snipe 6f25f80260 Added test 2026-05-26 10:40:45 +01:00
snipe 6da5f2e19b Fixed FD-55585 - check canceled_by_admin more closely 2026-05-26 10:40:45 +01:00
snipe 518351eba1 Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-26 10:40:45 +01:00
snipe ce0ce8688b Fixed #19052 - PUT next_audit_date does not produce audit log entry 2026-05-26 10:40:26 +01:00
snipe 43be1e8364 Fixed FD-55580 - added selectlist gate and tests 2026-05-26 10:40:26 +01:00
snipe 6e749d34a4 Dev assets 2026-05-26 10:40:25 +01:00
snipe 6e55d78c19 Fixed tests 2026-05-26 10:40:02 +01:00
snipe 884dc926fe Fixed typo 2026-05-26 10:40:02 +01:00
snipe a383033ffa Chekc auth before assigning S3 temporary link 2026-05-26 10:40:02 +01:00
snipe 67fa473281 Pint 2026-05-26 10:40:02 +01:00
snipe 28b3e34a84 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 10:40:01 +01:00
snipe 72383fdbd7 Fixed RB-4158 - handle numeric values better 2026-05-26 10:39:47 +01:00
Joël Pittet 44f9101d93 Remove direct symfony crawler dev dependencies 2026-05-26 10:39:47 +01:00
snipe 9cab197651 Fixed RB-4138 - json validation on wonky params 2026-05-26 10:39:47 +01:00
snipe db4fcff1f3 Fixed RB-4136 - array to string conversion when people throw random crap at the API 2026-05-26 10:39:47 +01:00
snipe ea820ce99a Fixed rollbar for labels 2026-05-26 10:39:47 +01:00
snipe d21ff001bf Fixed RB-4131 depreciation name error 2026-05-26 10:39:47 +01:00
snipe 69ddde697a Merge pull request #19061 from netresearch/ext-exif-required
Declare ext-exif as a required PHP extension
2026-05-26 10:04:41 +01:00
snipe 3f72d0afd8 Allow query string or parameter for byserial 2026-05-26 10:03:38 +01:00
snipe 1d209155f2 Add @CybotTM as a contributor 2026-05-26 10:03:38 +01:00
snipe 20b2d22991 Merge pull request #19064 from netresearch/upstream-queue-connection-rename
Fix dead QUEUE_DRIVER env var name in templates and test config
2026-05-26 10:02:19 +01:00
Sebastian Mendel e12ac03dd8 Add exif to Dockerfile.fpm-alpine extension list
Matches Dockerfile.alpine which already lists php84-exif explicitly.

---
Disclosure: drafted with a coding agent's help.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
2026-05-25 21:26:37 +02:00
Sebastian Mendel 9c73b26cd1 Declare ext-exif as a required PHP extension
ImageUploadRequest::__construct() unconditionally calls Image::make(...)
->orientate() on every uploaded image (asset photos, user avatars,
company logos, etc.). Intervention\Image\Commands\ExifCommand throws
NotSupportedException when ext-exif is unavailable; ImageUploadRequest
catches NotReadableException but not NotSupportedException, so the
exception surfaces to the user as an unhandled 500 for any image
upload that carries an EXIF Orientation tag (i.e. virtually every
smartphone photo).

Add ext-exif to the require block so composer install fails fast
instead of letting the gap surface as a runtime 500.

---
Disclosure: drafted with a coding agent's help while investigating
which PHP extensions a containerized Snipe-IT deployment needs but
the package manifest doesn't declare.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
2026-05-25 21:26:37 +02:00
Sebastian Mendel cabc842f52 Fix dead QUEUE_DRIVER env var name in templates and test config
`config/queue.php` reads `env('QUEUE_CONNECTION', 'sync')` since the
Laravel Shift in v6.0.0 (commit cc3c59bf97), but seven .env templates
and phpunit.xml still set `QUEUE_DRIVER` — the old Laravel <5.7 name
that the framework no longer reads. The default is `sync` anyway so
the gap is silent; but anyone copying these templates and trying to
enable an async driver (redis, database, beanstalkd, sqs) finds their
setting silently ignored.

Rename across:
- .env.example
- .env.docker
- .env.dev.docker
- .env.dusk.example
- docker/docker-secrets.env
- docker/docker.env
- phpunit.xml (XML <env> tag)

No code change. Default value `sync` preserved everywhere.

---
Disclosure: drafted with a coding agent's help.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
2026-05-25 21:26:17 +02:00
snipe d099cbd8e5 Merge pull request #19067 from chrisnox/patch-1
Update README.md
2026-05-25 15:03:24 +01:00
chrisnox 33846b0d61 Update README.md 2026-05-25 01:35:25 +02:00
chrisnox b7df1dcefb Update README.md 2026-05-25 01:35:25 +02:00
snipe e7c80b89eb Scope assets, locations, etc to the target, not the admin 2026-05-20 19:10:51 +01:00
snipe 50ba979840 Nicer formatting on user edit page when you cannot edit auth fields 2026-05-20 18:52:40 +01:00
snipe 6fd834e4d2 Tweaked light-label a little more 2026-05-20 18:48:17 +01:00
snipe 6ae09e15fb Updated tests and transformers 2026-05-20 16:17:02 +01:00
snipe f03b27ec88 Updated validator to accept single company_id or array 2026-05-20 15:08:53 +01:00
snipe cc1e0d82dd Tweaked label CSS 2026-05-20 15:08:13 +01:00
snipe f233bd2d01 New link formatter for BS tables 2026-05-20 15:07:11 +01:00
snipe 7a8b22df26 Updated users select2 to use new data-dash 2026-05-20 15:07:00 +01:00
snipe 17df4a08a7 Updated JS to add the array endpoint for company_ids (plural) 2026-05-20 15:04:02 +01:00
snipe c377b41198 Updated controllers 2026-05-20 14:58:18 +01:00
snipe e9e9dfeeab Load companies to avoid n+1 2026-05-19 14:40:39 +01:00
snipe f8c084cde7 This is hacky - might need to revisit 2026-05-19 14:40:20 +01:00
snipe 8f7fa6c0f5 Use new label for view-assets (not sure if I like this yet) 2026-05-19 14:39:53 +01:00
snipe f381362130 Tweaked company select for multiple if FMCS is enabled 2026-05-19 14:39:27 +01:00
snipe bef4a50720 Use multi-select in bulk user edit 2026-05-19 14:39:04 +01:00
snipe 2a93de675f Handle pipe delimited companyes in user importer 2026-05-19 14:38:52 +01:00
snipe e5f41f8f17 Use more common companies string 2026-05-19 14:38:28 +01:00
snipe b9da8ee55c Use multi-select for create user modal 2026-05-19 14:37:26 +01:00
snipe bf525f7213 Tweaked label + style for table labels (say that 100 times fast) 2026-05-19 14:37:08 +01:00
snipe c9ef163142 Added trans_choice option for company/companies 2026-05-19 14:36:36 +01:00
snipe feb3bd58cf Fixed wrong reference in fallback 2026-05-19 14:31:17 +01:00
snipe f9288e450b Update seeder 2026-05-19 14:24:24 +01:00
snipe 541128dd7a Updated tranformers 2026-05-19 14:23:44 +01:00
snipe 23b9c881ad Updated presenter 2026-05-19 14:16:45 +01:00
snipe cacd6f7e9b Add pipe separator to import more than one company for a user 2026-05-19 13:26:38 +01:00
snipe 4db4314f18 Added getCurrentUserCompanyIds (plural) to Company model 2026-05-19 13:26:06 +01:00
snipe 51aa66a77d Changed formatting just a bit 2026-05-19 13:24:49 +01:00
snipe aa0b491080 Added tests 2026-05-19 13:04:16 +01:00
snipe c01c9201ee Updated to use multiple select on users edit/create 2026-05-19 13:04:05 +01:00
snipe 0ad1a5b6ba Changed size of divs 2026-05-19 13:02:57 +01:00
snipe 95909d552a Show the list of companies if the infoPanelObj has more than one 2026-05-19 11:22:24 +01:00
snipe a159c3b84e Scary scary migration
We don’t actually drop the company_id field here, but later code will stop using it on the users table. This migration creates and populates the pivot table
2026-05-19 11:16:36 +01:00
snipe 19f70656ee Move API singletons from SettingServiceProvider into middleware 2026-05-13 22:20:46 +01:00
93 changed files with 55704 additions and 289 deletions
+9
View File
@@ -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
View File
@@ -113,7 +113,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -120,7 +120,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -72,7 +72,7 @@ CORS_ALLOWED_ORIGINS="*"
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
# --------------------------------------------
# OPTIONAL: LOGIN THROTTLING
+2 -1
View File
@@ -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
View File
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
| [<img src="https://avatars.githubusercontent.com/u/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!
+1
View File
@@ -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 \
+1
View File
@@ -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');
+24 -8
View File
@@ -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) {
+2 -2
View File
@@ -277,7 +277,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users)
->with('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
+2
View File
@@ -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);
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ class ItemImportRequest extends FormRequest
$classString = "App\\Importer\\{$class}Importer";
$importer = new $classString($filename);
$import->field_map = request('column-mappings');
$import->created_by = 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) ? [
+15 -4
View File
@@ -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),
+63 -5
View File
@@ -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) {
+2 -1
View File
@@ -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
View File
@@ -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);
}
}
/**
+2 -1
View File
@@ -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
View File
@@ -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.
*
+42 -11
View File
@@ -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;
}
}
+9
View File
@@ -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,
+3 -3
View File
@@ -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',
+4 -4
View File
@@ -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',
+6
View File
@@ -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());
});
}
}
-37
View File
@@ -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)
+7 -2
View File
@@ -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;
}
}
+1
View File
@@ -19,6 +19,7 @@
"require": {
"php": "^8.2",
"ext-curl": "*",
"ext-exif": "*",
"ext-fileinfo": "*",
"ext-iconv": "*",
"ext-json": "*",
+4
View File
@@ -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');
}
};
+37 -20
View File
@@ -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);
+1 -1
View File
@@ -51,4 +51,4 @@ SECURE_COOKIES=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
+1 -1
View File
@@ -60,4 +60,4 @@ SECURE_COOKIES=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
+1 -1
View File
@@ -23,7 +23,7 @@
<env name="CACHE_DRIVER" value="array"/>
<env name="MAIL_FROM_ADDR" value="app@example.com"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<ini name="display_errors" value="true"/>
</php>
+53227 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -1,9 +1,9 @@
{
"/js/dist/all.js": "/js/dist/all.js?id=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",
+1 -1
View File
@@ -210,7 +210,7 @@ $(function () {
search: params.term,
page: params.page || 1,
statusType: link.data("asset-status-type"),
companyId: link.data("company-id"),
companyId: link.data("company-ids") || link.data("company-id"),
};
return data;
},
+1
View File
@@ -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)
&nbsp;
@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!=''))
+1 -1
View File
@@ -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 -->
+2 -2
View File
@@ -119,10 +119,10 @@
@endif
@include ('partials.forms.checkout-selector', ['user_select' => 'true','asset_select' => 'true', 'location_select' => 'true'])
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.user'), 'fieldname' => 'assigned_user', '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;'])
+20 -2
View File
@@ -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,
+2 -2
View File
@@ -74,8 +74,8 @@
@endif
@include ('partials.forms.checkout-selector', ['user_select' => 'true','asset_select' => 'true', 'location_select' => 'false'])
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.user'), 'fieldname' => 'assigned_to', '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' : '' }}">
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -16,8 +16,8 @@
</div>
<!-- Setup of default company, taken from asset creator if scoped locations are activated in the settings -->
@if (($snipeSettings->scope_locations_fmcs == '1') && ($user->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 -->
+2 -2
View File
@@ -30,8 +30,8 @@
<div class="alert alert-danger" id="modal_error_msg" style="display:none">
</div>
<!-- Setup of default company, taken from asset creator -->
@if ($user->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 : '' }}
+2 -2
View File
@@ -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') !!}
+3 -3
View File
@@ -64,13 +64,13 @@
<!-- Company -->
@if (\App\Models\Company::canManageUsersCompanies())
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.select_company'), 'fieldname' => 'company_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>
+11 -3
View File
@@ -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>
+14
View File
@@ -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
View File
@@ -655,7 +655,7 @@ Route::group(['middleware' => 'web'], function () {
Route::post(
'two-factor',
[LoginController::class, 'postTwoFactorAuth']
);
)->middleware('throttle:two_factor');
Route::post(
'password/email',
+1 -1
View File
@@ -19,7 +19,7 @@ Route::group(['prefix' => 'licenses', 'middleware' => ['auth']], function () {
Route::post(
'{licenseId}/checkout/{seatId?}',
[Licenses\LicenseCheckoutController::class, 'store']
); // name() 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')));
}
}
+17
View File
@@ -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');
}
}