Compare commits

...

241 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
snipe 45df8ea55e Include table prefixes on OAuth Clients 2026-05-25 13:34:57 +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 3b0278bd3a Nullsafe on requesting user 2026-05-23 02:11:36 +01:00
snipe 3cff19f9ca Account for deleted adminuser in journal note for assets 2026-05-23 01:51:13 +01:00
snipe 4380a46d1c Fixed flaky test 2026-05-23 01:42:49 +01:00
snipe 6a1c3e29d0 Updated dev assets 2026-05-22 13:05:41 +01:00
snipe 9fc37cf6b9 Fixed parenthases 2026-05-22 13:03:20 +01:00
snipe b37adb8c49 Moved password visibility toggle to snipeit.js 2026-05-22 13:00:18 +01:00
snipe e3afe3b74d Fixed #19042 - use markdown for demo settings 2026-05-22 12:44:26 +01:00
snipe ee3ebe32e2 Added newline 2026-05-22 12:28:14 +01:00
snipe 3060fd305b More pint compliance 2026-05-22 12:27:09 +01:00
snipe 72666cdd47 Bumped hash and improved the version console command 2026-05-22 12:25:36 +01:00
snipe 4fbd6b2f15 Don’t show the serial field if the license does not have one 2026-05-22 12:14:52 +01:00
snipe 10ee84cb26 Bumped hash and added pre-version 2026-05-22 12:05:57 +01:00
snipe 432e625186 Fixed #19057 - update last login on google auth 2026-05-22 12:00:47 +01:00
snipe d011ad3dde Merge pull request #19058 from grokability/bulk-seat-checkin-toolbar
🎥 Bulk checkin license seats
2026-05-22 11:56:19 +01:00
snipe 54d01409dc Added to assets license tab as well 2026-05-22 11:45:53 +01:00
snipe d5ce5a82de Bulk checkin license seats 2026-05-22 11:30:22 +01:00
snipe b224cc636c Show number of selected, use checkboxEnabledFormatter on simple toolbars 2026-05-22 10:44:00 +01:00
snipe 5efb21eb0b Merge pull request #19056 from grokability/bulk-checkin-assets-from-ui
Bulk checkin assets from UI
2026-05-22 09:31:29 +01:00
snipe ec24da12a1 Use intended() for redirect back to where you were 2026-05-21 22:28:14 +01:00
snipe 6aa8d8e772 Added bulk checkin controller method 2026-05-21 22:04:47 +01:00
snipe 424ed48d06 Added strings (to do: combine these maybe?) 2026-05-21 22:04:33 +01:00
snipe 3c44ce8682 Added checkin option to bulk asset menu 2026-05-21 22:04:00 +01:00
snipe 948dadc333 Added blade 2026-05-21 22:03:36 +01:00
snipe 0ff2fb5cff Added route 2026-05-21 22:03:19 +01:00
snipe c0773772f4 Created test 2026-05-21 22:03:11 +01:00
snipe 3c1b18919a Graceful redirect if the user is not allowed 2026-05-21 21:12:35 +01:00
snipe 978c8f81a5 Added test 2026-05-21 20:15:50 +01:00
snipe ac2162113d Fixed FD-55585 - check canceled_by_admin more closely 2026-05-21 20:10:26 +01:00
snipe 34b4cf12e2 Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-21 16:12:19 +01:00
snipe fec0a1b2b5 Fixed #19052 - PUT next_audit_date does not produce audit log entry 2026-05-21 16:11:08 +01:00
snipe 4f943d4a7a Fixed FD-55580 - added selectlist gate and tests 2026-05-21 15:25:09 +01:00
snipe 37361ef52f Dev assets 2026-05-21 15:09:15 +01:00
snipe d97f579761 Fixed tests 2026-05-21 15:06:19 +01:00
snipe afc287b607 Fixed typo 2026-05-21 15:06:19 +01:00
snipe ded6515cbc Chekc auth before assigning S3 temporary link 2026-05-21 15:06:19 +01:00
snipe 1af9b42d82 Pint 2026-05-21 15:06:19 +01:00
snipe 403f9c848b Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-21 15:06:19 +01:00
snipe 480d252173 Fixed RB-4158 - handle numeric values better 2026-05-21 15:06:19 +01:00
snipe d329e5f862 Merge pull request #19053 from ubc-cpsc/fix/update-symfony-dev-dependencies
Remove direct symfony dom-crawler and css-selector dev dependencies
2026-05-21 13:55:26 +01:00
Joël Pittet d9bc110868 Remove direct symfony crawler dev dependencies 2026-05-20 18:59:45 -07: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 f74fedb226 Fixed RB-4138 - json validation on wonky params 2026-05-20 18:08:28 +01:00
snipe 5976e93de2 Fixed RB-4136 - array to string conversion when people throw random crap at the API 2026-05-20 18:04:43 +01:00
snipe 34101c148f Fixed rollbar for labels 2026-05-20 18:02:34 +01:00
snipe 048a46b317 Fixed RB-4131 depreciation name error 2026-05-20 16:20:13 +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 be5b74af90 Fixed RB-21535 - use withTrashed for maintenances on Activity Report 2026-05-19 08:38:03 +01:00
snipe 5dcc8efcca Check for IDs in bulk actions 2026-05-19 08:08:59 +01:00
snipe 8748ddffd8 Merge pull request #19039 from grokability/add-rp-to-maintenances
🖼️ Add custom maintenance types, responsible party and assigned to to maintenances
2026-05-18 20:14:20 +01:00
snipe e19a9b23e5 Merge pull request #19043 from marcusmoore/fixes/18624-license-checkout-button-url
Fixed #18624: Remove trailing slashes in license urls
2026-05-18 20:13:34 +01:00
Marcus Moore 5752fe68f0 Remove trailing slash in urls 2026-05-18 12:03:50 -07:00
snipe a6bbf0edf0 Used lower PHP requirement in composer lock 2026-05-18 16:31:22 +01:00
snipe 4a0797d59f Fixed RB-21533 - Undefined variable $indirectItemsCount on bulk print, added test 2026-05-18 16:26:15 +01:00
snipe 07f1f247de Merge pull request #19041 from grokability/dependabot/github_actions/develop/github/codeql-action-4
Bump github/codeql-action from 3 to 4
2026-05-18 16:18:55 +01:00
snipe 559491d31a Updated composer 2026-05-18 16:17:52 +01:00
snipe 1f51155c92 Updated string for clarity (notes vs journal notes) 2026-05-18 16:15:23 +01:00
snipe 58f7370935 Updated test with correct hash 2026-05-18 16:13:36 +01:00
snipe 043292ff15 Finishing touches 2026-05-18 16:13:13 +01:00
snipe a04bf04900 Mark type as required 2026-05-18 16:11:16 +01:00
snipe 9408f4005c Updated migration 2026-05-18 16:06:37 +01:00
snipe f9567af55a Added/updated tests 2026-05-18 16:01:27 +01:00
snipe 62a0c3764e Pass URL param to the index 2026-05-18 15:45:59 +01:00
snipe 66cab56c47 Added notes 2026-05-18 15:45:42 +01:00
snipe dfa8590a65 Added type to breadcrumbs 2026-05-18 15:42:52 +01:00
dependabot[bot] ee10cc970f Bump github/codeql-action from 3 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 14:36:24 +00:00
snipe 96a42d0f33 Added a few more scopes 2026-05-18 15:32:00 +01:00
snipe 57b257057a Moved scopes 2026-05-18 15:12:18 +01:00
snipe c45040818d Move the previous query scopes into the builder 2026-05-18 15:12:08 +01:00
snipe fe2d599099 Reorder bs table buttons 2026-05-18 15:00:21 +01:00
snipe 1d2ba0a8c1 Added builder for scopes 2026-05-18 14:59:11 +01:00
snipe 63454f8c63 Respect the view encrypted custom fields permission 2026-05-18 14:18:58 +01:00
snipe 87d6328fb8 One more test tweak 2026-05-18 13:54:30 +01:00
snipe 5020aec71a Added action_type to tests 2026-05-18 13:51:04 +01:00
snipe 39ff553b3e Fixed encrypted custom fields escaping 2026-05-18 13:49:51 +01:00
snipe aea3877718 Updated gate 2026-05-18 13:39:33 +01:00
snipe dbaa900444 Fixed redirect on assets 2026-05-18 13:29:32 +01:00
snipe 26382eb0a1 Better handle manufacturer in info panel 2026-05-18 13:12:13 +01:00
snipe 50baed175f Removed elibyy/tcpdf-laravel - we call it directly now
- The wrapper package is no longer a dependency
- PDF generation (labels, checkout acceptance) continues to work via the raw TCPDF class
2026-05-18 12:46:26 +01:00
snipe d870b3625b This is hacky - need to figure out what’s the root problem 2026-05-18 12:42:53 +01:00
snipe ae2e51c66c Use CarbonInterval for token expiration 2026-05-18 12:41:52 +01:00
snipe 826bbe37c9 Fixed #19035 - Added helptext and parsing back into manufacturer 2026-05-18 12:33:21 +01:00
snipe 27a637a7a4 Added assets-only user to demo 2026-05-18 12:18:49 +01:00
snipe 56d5f17dde Fixed #19036 - added table prefix to migration 2026-05-18 12:03:31 +01:00
snipe b9c7bcf035 Check for null in SCIM 2026-05-18 11:56:14 +01:00
snipe 071c46a91e Fixed copy button 2026-05-18 11:52:05 +01:00
snipe 49138f2cb1 Fixed link 2026-05-18 11:49:27 +01:00
snipe fd46794350 Added copy JS 2026-05-18 11:29:32 +01:00
snipe 9305c3e845 Moved strings 2026-05-18 11:28:47 +01:00
snipe 058da6bfef Added maintenance type strings 2026-05-18 11:09:48 +01:00
snipe a65ae59810 Changed phrasing 2026-05-18 11:09:26 +01:00
snipe 1967b3b7a7 Stubbed index and edit for maintenance types 2026-05-18 11:08:48 +01:00
snipe 30dbf1698b Added modal to create new maintenance type 2026-05-18 11:08:11 +01:00
snipe 8e1ad53a31 Switch to maintenance type table for maintenance type dropdown partial 2026-05-18 11:07:58 +01:00
snipe a5272968de Added hardware maintenance complete route 2026-05-18 11:07:14 +01:00
snipe 94e14e5ee9 Added tests 2026-05-18 11:06:54 +01:00
snipe db46e16530 Pint 2026-05-18 11:06:41 +01:00
snipe 0630ef9f89 Added routes 2026-05-18 11:06:21 +01:00
snipe aae07bd3a7 Null guard for null created_at date 2026-05-15 01:56:22 +01:00
snipe 1cff2d67aa Added extra guard on user permission 2026-05-15 00:43:18 +01:00
snipe 80418d0b16 Fixed error formatting 2026-05-15 00:43:18 +01:00
snipe 14c5cff429 Added CheckinAndDeleteItems command 2026-05-15 00:43:18 +01:00
snipe 0ea6eb13c2 Merge pull request #19032 from grokability/optimize-incorrect-checkout-acceptances-command
Optimize incorrect checkout acceptances command
2026-05-14 23:58:19 +01:00
snipe 86cc20034f Added test 2026-05-14 23:11:03 +01:00
snipe 57f17e80a2 First pass 2026-05-14 23:03:59 +01:00
snipe 12c3629c89 Added empty option to allow clear 2026-05-14 16:57:32 +01:00
snipe 0a02c0b81a fixed test 2026-05-14 16:40:44 +01:00
snipe fdcc3f1968 Clearer messaging on acceptance 2026-05-14 16:03:24 +01:00
snipe 663bab1f9d Merge pull request #19030 from grokability/purchase-cost-fix
Fixed #19029 - switch back to text for purchase cost
2026-05-14 15:56:25 +01:00
snipe 19f70656ee Move API singletons from SettingServiceProvider into middleware 2026-05-13 22:20:46 +01:00
232 changed files with 14760 additions and 77275 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
@@ -63,7 +63,7 @@ jobs:
sarif-result-file: "ethicalcheck-results.sarif"
- name: Upload sarif file to repository
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ./ethicalcheck-results.sarif
+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.
@@ -0,0 +1,308 @@
<?php
namespace App\Console\Commands;
use App\Events\CheckoutableCheckedIn;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Component;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class CheckinAndDeleteItems extends Command
{
protected $signature = 'snipeit:checkin-delete-all
{--company-id= : Only process items belonging to this company ID}
{--admin-id= : ID of the user credited for the checkins (defaults to first superadmin)}
{--no-notifications : Suppress email and webhook notifications}
{--type=all : Comma-separated types to process: assets, licenses, accessories, components, or all}
{--note= : Note recorded on each checkin action log entry}
{--dry-run : Preview what would be processed without making any changes}
{--force : Skip the confirmation prompt}';
protected $description = 'Check in all assigned items and soft-delete them, optionally scoped to a company';
public function handle(): int
{
$companyId = $this->option('company-id');
$noNotifications = $this->option('no-notifications');
$dryRun = $this->option('dry-run');
$typeOption = $this->option('type') ?? 'all';
$note = $this->option('note') ?: 'Checked in and deleted via CLI';
$allTypes = ['assets', 'licenses', 'accessories', 'components'];
$typesToProcess = $typeOption === 'all'
? $allTypes
: array_intersect(array_map('trim', explode(',', $typeOption)), $allTypes);
if (empty($typesToProcess)) {
$this->error('Invalid --type value. Use: assets, licenses, accessories, components, or all.');
return 1;
}
$admin = null;
if (! $dryRun && ! $noNotifications) {
if ($this->option('admin-id')) {
$admin = User::find($this->option('admin-id'));
if (! $admin) {
$this->error('No user found with admin-id '.$this->option('admin-id').'.');
return 1;
}
} else {
$admin = User::onlySuperAdmins()->first();
}
if (! $admin) {
$this->warn('No admin user found — notifications will be suppressed.');
$noNotifications = true;
}
}
$scopeMsg = $companyId ? "company ID {$companyId}" : 'all companies';
$typesMsg = implode(', ', $typesToProcess);
if ($dryRun) {
$this->warn('DRY RUN — no changes will be made.');
} elseif (! $this->option('force')) {
if (! $this->confirm("This will check in and soft-delete all [{$typesMsg}] for [{$scopeMsg}]. Continue?")) {
$this->info('Aborted.');
return 0;
}
}
if (in_array('assets', $typesToProcess)) {
$this->processAssets($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('licenses', $typesToProcess)) {
$this->processLicenses($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('accessories', $typesToProcess)) {
$this->processAccessories($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('components', $typesToProcess)) {
$this->processComponents($companyId, $noNotifications, $note, $admin, $dryRun);
}
if ($dryRun) {
$this->warn('Dry run complete — no changes were made.');
}
return 0;
}
private function processAssets(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Asset::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$assets = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($assets as $asset) {
if ($asset->assignedTo) {
if ($dryRun) {
$this->line(' Would check in asset: '.$asset->asset_tag.' (assigned to '.$asset->assignedTo->name.')');
} else {
$target = $asset->assignedTo;
$checkin_at = now()->format('Y-m-d H:i:s');
$originalValues = $asset->getRawOriginal();
if ($noNotifications) {
DB::table('assets')->where('id', $asset->id)
->update(['assigned_to' => null, 'assigned_type' => null]);
$asset->logCheckin($target, $note, $checkin_at, $originalValues);
} else {
// Fire event before clearing so the log captures the original state
event(new CheckoutableCheckedIn($asset, $target, $admin, $note, $checkin_at, $originalValues));
DB::table('assets')->where('id', $asset->id)
->update(['assigned_to' => null, 'assigned_type' => null]);
}
$asset->licenseseats()->update(['assigned_to' => null]);
CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class], fn (Builder $q) => $q->where('id', $asset->id))
->delete();
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete asset: '.$asset->asset_tag);
} else {
$asset->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Assets: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
private function processLicenses(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = License::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$licenses = $query->get();
$seatsCheckedIn = 0;
$deleted = 0;
foreach ($licenses as $license) {
$seats = LicenseSeat::where('license_id', $license->id)
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
->get();
foreach ($seats as $seat) {
$target = $seat->assigned_to ? $seat->user : $seat->asset;
if ($dryRun) {
$this->line(' Would check in license seat for: '.$license->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
} else {
$seat->assigned_to = null;
$seat->asset_id = null;
$seat->save();
if ($target) {
if ($noNotifications) {
$seat->logCheckin($target, $note);
} else {
event(new CheckoutableCheckedIn($seat, $target, $admin, $note));
}
}
}
$seatsCheckedIn++;
}
if ($dryRun) {
$this->line(' Would delete license: '.$license->name);
} else {
$license->licenseseats()->delete();
$license->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Licenses: {$seatsCheckedIn} seats {$action} checked in, {$deleted} licenses {$action} deleted.");
}
private function processAccessories(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Accessory::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$accessories = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($accessories as $accessory) {
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
foreach ($checkouts as $checkout) {
$target = $checkout->assignedTo;
if ($dryRun) {
$this->line(' Would check in accessory: '.$accessory->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
} else {
$checkin_at = now()->format('Y-m-d H:i:s');
$checkout->delete();
if ($target) {
if ($noNotifications) {
$accessory->logCheckin($target, $note, $checkin_at);
} else {
event(new CheckoutableCheckedIn($accessory, $target, $admin, $note, $checkin_at));
}
}
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete accessory: '.$accessory->name);
} else {
$accessory->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Accessories: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
private function processComponents(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Component::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$components = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($components as $component) {
$assignments = DB::table('components_assets')
->where('component_id', $component->id)
->get();
foreach ($assignments as $assignment) {
$asset = Asset::find($assignment->asset_id);
if ($dryRun) {
$this->line(' Would check in component: '.$component->name.' (assigned to '.($asset?->asset_tag ?? 'unknown').')');
} else {
$checkin_at = now()->format('Y-m-d H:i:s');
DB::table('components_assets')->where('id', $assignment->id)->delete();
if ($asset) {
if ($noNotifications) {
$component->logCheckin($asset, $note, $checkin_at);
} else {
event(new CheckoutableCheckedIn($component, $asset, $admin, $note, $checkin_at));
}
}
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete component: '.$component->name);
} else {
$component->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Components: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
}
@@ -30,41 +30,77 @@ class CleanIncorrectCheckoutAcceptances extends Command
{
$deletions = 0;
$skips = 0;
$total = CheckoutAcceptance::count();
// This walks *every* checkoutacceptance. That's gnarly. But necessary
$this->withProgressBar(CheckoutAcceptance::all(), function ($checkoutAcceptance) use (&$deletions, &$skips) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
if (is_null($item)) {
$this->info("'Checkoutable' Item is null, going to next record");
$this->info("Processing {$total} checkout acceptances...");
$bar = $this->output->createProgressBar($total);
$bar->start();
return; // 'false' allegedly breaks execution entirely, so 'true' maybe doesn't? hrm. just straight return maybe?
}
if (get_class($item) == LicenseSeat::class) {
$item = $item->license;
}
foreach ($item->assetlog()->where('action_type', 'checkout')->get() as $assetlog) {
if ($assetlog->target_id == $checkout_to_id && $assetlog->target_type != User::class) {
// We have a checkout-to an ID for a non-User, which matches to an ID in the checkout_acceptances table
// Chunk to avoid loading the whole table into memory; eager-load checkoutable
// to eliminate the N+1 on that relationship.
CheckoutAcceptance::with('checkoutable')
->chunkById(500, function ($chunk) use (&$deletions, &$skips, $bar) {
$idsToDelete = [];
// now, let's compare the _times_ - are they close?
// I'm picking `created_at` over `action_date` because I'm more interested in when the actionlogs
// were _created_, not when they were alleged to have happened - those created_at times need to be within 'X' seconds of
// each other (currently 5)
if ($assetlog->created_at->diffInSeconds($checkoutAcceptance->created_at, true) <= 5) { // we're allowing for five _ish_ seconds of slop
$deletions++;
$checkoutAcceptance->forceDelete(); // HARD delete this record; it should have never been
foreach ($chunk as $checkoutAcceptance) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
return;
} else {
// $this->info("The two records are too far apart");
if (is_null($item)) {
$skips++;
$bar->advance();
continue;
}
} else {
// $this->info("No match! checkout to id: " . $checkout_to_id." target_id: ".$assetlog->target_id." target_type: ".$assetlog->target_type);
if (get_class($item) === LicenseSeat::class) {
$item = $item->license;
if (is_null($item)) {
$skips++;
$bar->advance();
continue;
}
}
if (is_null($checkoutAcceptance->created_at)) {
$skips++;
$bar->advance();
continue;
}
// Push all filtering (including the ±5-second window) into the DB;
// exists() returns as soon as one matching row is found rather than
// fetching all checkout logs into PHP.
$shouldDelete = $item->assetlog()
->where('action_type', 'checkout')
->where('target_id', $checkout_to_id)
->where('target_type', '!=', User::class)
->whereBetween('created_at', [
$checkoutAcceptance->created_at->copy()->subSeconds(5),
$checkoutAcceptance->created_at->copy()->addSeconds(5),
])
->exists();
if ($shouldDelete) {
$idsToDelete[] = $checkoutAcceptance->id;
$deletions++;
} else {
$skips++;
}
$bar->advance();
}
}
$skips++;
});
$this->error("Final deletion count: $deletions, and skip count: $skips");
// Bulk-delete the bad records in one query per chunk instead of one per row.
if (! empty($idsToDelete)) {
CheckoutAcceptance::whereIn('id', $idsToDelete)->forceDelete();
}
});
$bar->finish();
$this->newLine();
$this->info("Final deletion count: {$deletions}, and skip count: {$skips}");
}
}
+40 -1
View File
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
class ResetDemoSettings extends Command
{
@@ -47,7 +48,7 @@ class ResetDemoSettings extends Command
$settings->auto_increment_assets = 1;
$settings->logo = 'snipe-logo.png';
$settings->alert_email = 'service@snipe-it.io';
$settings->login_note = 'Use `admin` / `password` to login to the demo.';
$settings->login_note = "Use any of the following credentials to login to the demo:\n\n- `admin` / `password`\n- `assets` / `password`\n- `testuser` / `password`";
$settings->header_color = '#3c8dbc';
$settings->link_dark_color = '#5fa4cc';
$settings->link_light_color = '#296282;';
@@ -85,6 +86,44 @@ class ResetDemoSettings extends Command
$user->save();
}
$assetsUser = User::updateOrCreate(
['username' => 'assets'],
[
'first_name' => 'Assets',
'last_name' => 'User',
'password' => Hash::make('password'),
'activated' => 1,
]
);
$assetsUser->permissions = json_encode([
'assets.view' => 1,
'assets.create' => 1,
'assets.edit' => 1,
'assets.delete' => 1,
'assets.checkout' => 1,
'assets.checkin' => 1,
'assets.audit' => 1,
'assets.files' => 1,
'assets.view.requestable' => 1,
'assets.view.encrypted_custom_fields' => 1,
]);
$assetsUser->save();
$testUser = User::updateOrCreate(
['username' => 'testuser'],
[
'first_name' => 'Test',
'last_name' => 'User',
'password' => Hash::make('password'),
'activated' => 1,
]
);
$testUser->permissions = json_encode([
'self.checkout_assets' => 1,
'assets.view.requestable' => 1,
]);
$testUser->save();
\Storage::disk('public')->put('snipe-logo.png', file_get_contents(public_path('img/demo/snipe-logo.png')));
\Storage::disk('public')->put('snipe-logo-lg.png', file_get_contents(public_path('img/demo/snipe-logo-lg.png')));
+63 -52
View File
@@ -4,6 +4,9 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use function Laravel\Prompts\info;
use function Laravel\Prompts\select;
class Version extends Command
{
/**
@@ -11,7 +14,7 @@ class Version extends Command
*
* @var string
*/
protected $signature = 'version:update {--branch=master} {--type=patch}';
protected $signature = 'version:update';
/**
* The console command description.
@@ -37,30 +40,40 @@ class Version extends Command
*/
public function handle()
{
$use_branch = $this->option('branch');
$use_type = $this->option('type');
$use_branch = select(
label: 'Which branch?',
options: ['master', 'develop'],
default: 'develop',
);
$use_type = select(
label: 'Which release type?',
options: [
'hash' => 'Hash bump',
'patch' => 'Patch release',
'minor' => 'Minor release',
'major' => 'Major release',
'pre-patch' => 'Pre-patch release',
'pre-minor' => 'Pre-minor release',
'pre-major' => 'Pre-major release',
],
default: 'hash',
scroll: 7,
);
$git_branch = trim(shell_exec('git rev-parse --abbrev-ref HEAD'));
$build_version = trim(shell_exec('git rev-list --count '.$use_branch));
$versionFile = 'config/version.php';
$full_hash_version = str_replace("\n", '', shell_exec('git describe master --tags'));
$version = explode('-', $full_hash_version);
$app_version = $current_app_version = $version[0];
$app_version = $version[0];
$hash_version = (array_key_exists('2', $version)) ? $version[2] : '';
$prerelease_version = '';
$this->line('Branch is: '.$use_branch);
$this->line('Type is: '.$use_type);
$this->line('Current version is: '.$full_hash_version);
if (count($version) == 3) {
$this->line('This does not look like an alpha/beta release.');
} else {
if (array_key_exists('3', $version)) {
$this->line('The current version looks like a beta release.');
$prerelease_version = $version[1];
$hash_version = $version[3];
}
if (array_key_exists('3', $version)) {
$prerelease_version = $version[1];
$hash_version = $version[3];
}
$app_version_raw = explode('.', $app_version);
@@ -74,54 +87,52 @@ class Version extends Command
$patch = $app_version_raw[2];
}
if ($use_type == 'major') {
if ($use_type === 'major') {
$app_version = 'v'.($maj + 1).".$min.$patch";
} elseif ($use_type == 'minor') {
} elseif ($use_type === 'minor') {
$app_version = 'v'."$maj.".($min + 1).".$patch";
} elseif ($use_type == 'pre') {
$pre_raw = str_replace('beta', '', $prerelease_version);
$pre_raw = str_replace('alpha', '', $pre_raw);
$pre_raw = str_ireplace('rc', '', $pre_raw);
$pre_raw = $pre_raw++;
$this->line('Setting the pre-release to '.$prerelease_version.'-'.$pre_raw);
$app_version = 'v'."$maj.".($min + 1).".$patch";
} elseif ($use_type == 'patch') {
} elseif ($use_type === 'pre-patch') {
$app_version = 'v'."$maj.$min.".($patch + 1).'-pre';
} elseif ($use_type === 'pre-minor') {
$app_version = 'v'."$maj.".($min + 1).'.0-pre';
} elseif ($use_type === 'pre-major') {
$app_version = 'v'.($maj + 1).'.0.0-pre';
} elseif ($use_type === 'patch') {
$app_version = 'v'."$maj.$min.".($patch + 1);
// If nothing is passed, leave the version as it is, just increment the build
} else {
$app_version = 'v'."$maj.$min.".$patch;
}
// Determine if this tag already exists, or if this prior to a release
$this->line('Running: git rev-parse master '.$current_app_version);
// $pre_release = trim(shell_exec('git rev-parse '.$use_branch.' '.$current_app_version.' 2>&1 1> /dev/null'));
if ($use_branch == 'develop') {
if ($use_branch === 'develop' && ! str_ends_with($app_version, '-pre')) {
$app_version = $app_version.'-pre';
}
$full_hash_version = str_replace($version[0], $app_version, $full_hash_version);
$full_app_version = $app_version.' - build '.$build_version.'-'.$hash_version;
$array = var_export(
[
'app_version' => $app_version,
'full_app_version' => $full_app_version,
'build_version' => $build_version,
'prerelease_version' => $prerelease_version,
'hash_version' => $hash_version,
'full_hash' => $full_hash_version,
'branch' => $git_branch, ],
true
);
$content = <<<PHP
<?php
// Construct our file content
$content = <<<CON
<?php
return $array;
CON;
return [
'app_version' => '$app_version',
'full_app_version' => '$full_app_version',
'build_version' => '$build_version',
'prerelease_version' => '$prerelease_version',
'hash_version' => '$hash_version',
'full_hash' => '$full_hash_version',
'branch' => '$git_branch',
];
PHP;
// And finally write the file and output the current version
\File::put($versionFile, $content);
$this->info('Setting NEW version: '.$full_app_version.' ('.$git_branch.')');
info('New version: '.$full_app_version.' ('.$git_branch.')');
info('Building JS/CSS assets...');
passthru('npm run prod', $exitCode);
if ($exitCode !== 0) {
$this->error('Asset build failed with exit code '.$exitCode);
} else {
info('Assets built successfully.');
}
}
}
+3
View File
@@ -31,6 +31,9 @@ enum ActionType: string
case DeleteSeats = 'delete seats';
case AddSeats = 'add seats';
// Maintenances
case MaintenanceComplete = 'completed';
// File Uploads
case Uploaded = 'uploaded';
case UploadDeleted = 'upload deleted';
+7
View File
@@ -19,6 +19,7 @@ use Illuminate\Validation\ValidationException;
use Intervention\Image\Exception\NotSupportedException;
use JsonException;
use League\OAuth2\Server\Exception\OAuthServerException;
use Livewire\Exceptions\PublicPropertyNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
@@ -41,6 +42,7 @@ class Handler extends ExceptionHandler
JsonException::class,
SCIMException::class, // these generally don't need to be reported
InvalidFormatException::class,
PublicPropertyNotFoundException::class,
];
/**
@@ -71,6 +73,11 @@ class Handler extends ExceptionHandler
public function render($request, Throwable $e)
{
// Livewire tried to set a property that doesn't exist (e.g. stale browser state sending a bare "0" as a property name)
if ($e instanceof PublicPropertyNotFoundException) {
return response()->json(['message' => $e->getMessage()], 422);
}
// CSRF token mismatch error
if ($e instanceof TokenMismatchException) {
return redirect()->back()->with('error', trans('general.token_expired'));
+3 -2
View File
@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Helpers\Helper;
use App\Models\Actionlog;
use App\Models\Asset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
@@ -17,6 +16,9 @@ class ActionlogController extends Controller
{
$filename = basename((string) $filename);
$actionlog = Actionlog::where('accept_signature', $filename)->with('item')->firstOrFail();
$this->authorize('view', $actionlog->item);
// PHP doesn't let you handle file not found errors well with
// file_get_contents, so we set the error reporting for just this class
error_reporting(0);
@@ -29,7 +31,6 @@ class ActionlogController extends Controller
return redirect()->away(Storage::disk($disk)->temporaryUrl($file, now()->addMinutes(5)));
default:
$this->authorize('view', Asset::class);
$file = config('app.private_uploads').'/signatures/'.$filename;
$filetype = Helper::checkUploadIsImage($file);
@@ -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')));
}
@@ -401,6 +405,7 @@ class AccessoriesController extends Controller
*/
public function selectlist(Request $request)
{
$this->authorize('view.selectlists');
$accessories = Accessory::select([
'accessories.id',
@@ -590,6 +590,7 @@ class AssetsController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$assets = Asset::select([
'assets.id',
@@ -602,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')));
}
@@ -356,6 +356,8 @@ class ConsumablesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$consumables = Consumable::select([
'consumables.id',
'consumables.name',
@@ -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
*
@@ -268,6 +435,8 @@ class LicensesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$licenses = License::select([
'licenses.id',
'licenses.name',
@@ -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');
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\MaintenanceTypesTransformer;
use App\Models\MaintenanceType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', MaintenanceType::class);
$types = MaintenanceType::select(['id', 'name', 'created_at', 'updated_at', 'deleted_at']);
if ($request->input('deleted') == 'true') {
$types->onlyTrashed();
}
if ($request->filled('search')) {
$types->where('name', 'LIKE', '%'.$request->input('search').'%');
}
if ($request->filled('name')) {
$types->where('name', '=', $request->input('name'));
}
$offset = ($request->input('offset') > $types->count()) ? $types->count() : abs($request->input('offset'));
$limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort = in_array($request->input('sort'), ['id', 'name', 'created_at', 'updated_at']) ? $request->input('sort') : 'name';
$total = $types->count();
$types = $types->orderBy($sort, $order)->skip($offset)->take($limit)->get();
return (new MaintenanceTypesTransformer)->transformMaintenanceTypes($types, $total);
}
public function show(MaintenanceType $maintenanceType): JsonResponse|array
{
$this->authorize('view', $maintenanceType);
return (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType);
}
public function store(Request $request): JsonResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($type), trans('admin/maintenance_types/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $type->getErrors()));
}
public function update(Request $request, MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType), trans('admin/maintenance_types/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenanceType->getErrors()));
}
public function destroy(MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/maintenance_types/message.delete.success')));
}
}
@@ -2,15 +2,19 @@
namespace App\Http\Controllers\Api;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\MaintenancesTransformer;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -39,7 +43,7 @@ class MaintenancesController extends Controller
$maintenances = Maintenance::select('maintenances.*')
->whereHas('asset')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo', 'maintenanceType', 'responsibleParty', 'completedByUser');
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
@@ -62,8 +66,39 @@ class MaintenancesController extends Controller
$maintenances->where('maintenances.url', '=', $request->input('url'));
}
if ($request->filled('asset_maintenance_type')) {
$maintenances->where('asset_maintenance_type', '=', $request->input('asset_maintenance_type'));
if ($request->filled('maintenance_type')) {
$maintenances->where('maintenance_type', '=', $request->input('maintenance_type'));
}
if ($request->filled('maintenance_type_id')) {
$maintenances->where('maintenance_type_id', '=', $request->input('maintenance_type_id'));
}
if ($request->filled('responsible_party_id')) {
$maintenances->where('responsible_party_id', '=', $request->input('responsible_party_id'));
}
if ($request->filled('completed')) {
if ($request->input('completed') === 'true') {
$maintenances->completed();
} else {
$maintenances->active();
}
}
if ($request->filled('upcoming_status')) {
$settings = Setting::getSettings();
switch ($request->input('upcoming_status')) {
case 'due':
$maintenances->dueForCompletion($settings);
break;
case 'overdue':
$maintenances->overdueForCompletion();
break;
case 'due-or-overdue':
$maintenances->dueOrOverdueForCompletion($settings);
break;
}
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -74,10 +109,10 @@ class MaintenancesController extends Controller
'id',
'name',
'asset_maintenance_time',
'asset_maintenance_type',
'cost',
'start_date',
'completion_date',
'completed_at',
'notes',
'asset_tag',
'asset_name',
@@ -89,6 +124,7 @@ class MaintenancesController extends Controller
'status_label',
'model',
'model_number',
'maintenance_type',
];
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -96,31 +132,37 @@ class MaintenancesController extends Controller
switch ($sort) {
case 'created_by':
$maintenances = $maintenances->OrderByCreatedBy($order);
$maintenances = $maintenances->orderByCreatedBy($order);
break;
case 'supplier':
$maintenances = $maintenances->OrderBySupplier($order);
$maintenances = $maintenances->orderBySupplier($order);
break;
case 'asset_tag':
$maintenances = $maintenances->OrderByTag($order);
$maintenances = $maintenances->orderByTag($order);
break;
case 'asset_name':
$maintenances = $maintenances->OrderByAssetName($order);
$maintenances = $maintenances->orderByAssetName($order);
break;
case 'model':
$maintenances = $maintenances->OrderByAssetModelName($order);
$maintenances = $maintenances->orderByAssetModelName($order);
break;
case 'model_number':
$maintenances = $maintenances->OrderByAssetModelNumber($order);
$maintenances = $maintenances->orderByAssetModelNumber($order);
break;
case 'serial':
$maintenances = $maintenances->OrderByAssetSerial($order);
$maintenances = $maintenances->orderByAssetSerial($order);
break;
case 'location':
$maintenances = $maintenances->OrderLocationName($order);
$maintenances = $maintenances->orderLocationName($order);
break;
case 'status_label':
$maintenances = $maintenances->OrderStatusName($order);
$maintenances = $maintenances->orderStatusName($order);
break;
case 'maintenance_type':
$maintenances = $maintenances->orderByMaintenanceType($order);
break;
case 'completed_at':
$maintenances = $maintenances->orderByCompletedAt($order);
break;
default:
$maintenances = $maintenances->orderBy($sort, $order);
@@ -153,19 +195,60 @@ class MaintenancesController extends Controller
{
$this->authorize('update', Asset::class);
// create a new model instance
$maintenance = new Maintenance;
$maintenance->fill($request->all());
$maintenance->created_by = auth()->id();
$maintenance = $request->handleImages($maintenance);
// Was the asset maintenance created?
if ($maintenance->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.create.success')));
$isBulk = $request->has('asset_ids');
$assetIds = $isBulk
? array_values(array_filter((array) $request->input('asset_ids')))
: [$request->input('asset_id')];
$created = new EloquentCollection;
$errors = [];
foreach ($assetIds as $assetId) {
$asset = Asset::find($assetId);
if (! $asset) {
$errors[] = trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $assetId]);
continue;
}
if (! Company::isCurrentUserHasAccess($asset)) {
$errors[] = trans('general.action_permission_denied', ['item_type' => trans('general.asset'), 'id' => $assetId, 'action' => trans('general.create')]);
continue;
}
$maintenance = new Maintenance;
$maintenance->fill($request->except(['asset_id', 'asset_ids']));
$maintenance->asset_id = $assetId;
$maintenance->created_by = auth()->id();
$request->handleImages($maintenance);
if ($maintenance->save()) {
$created->push($maintenance->fresh());
} else {
$errors[] = $maintenance->getErrors();
}
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenance->getErrors()));
if ($isBulk) {
if ($created->isEmpty()) {
return response()->json(Helper::formatStandardApiResponse('error', null, count($errors) === 1 ? $errors[0] : $errors));
}
return response()->json(Helper::formatStandardApiResponse(
'success',
(new MaintenancesTransformer)->transformMaintenances($created, $created->count()),
trans('admin/maintenances/message.create.success')
));
}
// Single asset_id path — backward compatible response shape
if ($created->isNotEmpty()) {
return response()->json(Helper::formatStandardApiResponse('success', $created->first(), trans('admin/maintenances/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, ! empty($errors) ? $errors[0] : null));
}
/**
@@ -256,6 +339,35 @@ class MaintenancesController extends Controller
}
public function complete(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', Asset::class);
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $maintenance->id, 'action' => trans('admin/maintenances/form.mark_complete')])));
}
if ($maintenance->completed_at) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/maintenances/form.already_complete')));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenancesTransformer)->transformMaintenance($maintenance->fresh()), trans('admin/maintenances/message.complete.success')));
}
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
{
$this->authorize('history', $maintenance);
@@ -267,4 +379,50 @@ class MaintenancesController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
public function notesIndex(Maintenance $maintenance): JsonResponse
{
$this->authorize('journal', $maintenance);
$notes = Actionlog::with('user:id,username')
->where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'note added')
->orderBy('created_at', 'desc')
->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'item_type', 'action_type']);
$notesArray = $notes->map(fn ($note) => [
'id' => $note->id,
'created_at' => $note->created_at,
'note' => $note->note,
'created_by' => $note->created_by,
'username' => $note->user?->username,
'item_id' => $note->item_id,
'item_type' => $note->item_type,
'action_type' => $note->action_type,
]);
return response()->json(Helper::formatStandardApiResponse('success', ['notes' => $notesArray, 'maintenance_id' => $maintenance->id]));
}
public function notesStore(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', $maintenance);
if (! $request->filled('note')) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('validation.required', ['attribute' => 'note'])), 422);
}
$logaction = new Actionlog;
$logaction->item_type = Maintenance::class;
$logaction->created_by = auth()->id();
$logaction->item_id = $maintenance->id;
$logaction->note = $request->input('note');
if ($logaction->logaction('note added')) {
return response()->json(Helper::formatStandardApiResponse('success', ['note' => $logaction->note, 'item_id' => $maintenance->id], trans('general.note_added')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'Something went wrong'), 500);
}
}
@@ -36,18 +36,18 @@ class ReportsController extends Controller
// then they shouldn't be able to see the activity log for that item or target,
// but if they have the general activity view permission,
// then they can see all activity logs regardless of the item or target.
if ((! Gate::allows('activity.view')) && (($request->filled('target_type')) && ($request->filled('target_id'))) || (($request->filled('item_type')) && ($request->filled('item_id')))) {
if ((! Gate::allows('activity.view')) && (($request->filled('target_type') && $request->filled('target_id')) || ($request->filled('item_type') && $request->filled('item_id')))) {
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
$target = Helper::normalizeFullModelName(request()->input('target_type'));
$target::find(request()->input('target_id'))?->withTrashed();
$this->authorize('view', $target);
$targetClass = Helper::normalizeFullModelName(request()->input('target_type'));
$target = $targetClass::withTrashed()->find(request()->input('target_id'));
$this->authorize('view', $target ?? $targetClass);
}
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
$item = Helper::normalizeFullModelName(request()->input('item_type'));
$item::find(request()->input('item_id'))?->withTrashed();
$this->authorize('view', $item);
$itemClass = Helper::normalizeFullModelName(request()->input('item_type'));
$item = $itemClass::withTrashed()->find(request()->input('item_id'));
$this->authorize('view', $item ?? $itemClass);
}
} else {
+26 -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')) {
@@ -380,6 +380,8 @@ class UsersController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$users = User::select(
[
'users.id',
@@ -394,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'))
@@ -441,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')) {
@@ -486,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')));
}
@@ -575,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'));
}
@@ -607,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')));
}
@@ -135,12 +135,16 @@ class AssetCheckinController extends Controller
$asset->location_id = $asset->rtd_location_id;
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: '.$request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
if ($request->has('location_id')) {
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: '.$request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
}
} else {
// Explicitly submitted as empty — clear the location
$asset->location_id = null;
}
}
@@ -511,7 +511,7 @@ class AssetsController extends Controller
// Validate required serial based on model setting
if ($model && $model->require_serial === 1 && empty($serial[1])) {
return redirect()->to(Helper::getRedirectOption($request, $asset->id, 'Assets'))
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('warning', trans('admin/hardware/form.serial_required_post_model_update', [
'asset_model' => $model->name,
]));
@@ -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);
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Assets;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutablesCheckedOutInBulk;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
@@ -9,13 +10,16 @@ use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\CustomField;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\Statuslabel;
use App\View\Label;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -73,6 +77,16 @@ class BulkAssetsController extends Controller
return redirect()->route('hardware.bulkcheckout.show');
}
if ($request->input('bulk_actions') === 'checkin') {
$referer = request()->headers->get('referer');
if ($referer && parse_url($referer, PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST)) {
redirect()->setIntendedUrl($referer);
}
$request->session()->flashInput(['selected_assets' => $asset_ids]);
return redirect()->route('hardware.bulkcheckin.show');
}
if ($request->input('bulk_actions') === 'maintenance') {
$request->session()->flashInput(['selected_assets' => $asset_ids]);
@@ -759,6 +773,112 @@ class BulkAssetsController extends Controller
}
/**
* Show Bulk Checkin Page
*/
public function showCheckin(): View
{
$this->authorize('checkin', Asset::class);
$notAssigned = collect();
if (old('selected_assets') && is_array(old('selected_assets'))) {
$assets = Asset::findMany(old('selected_assets'));
[$assigned, $notAssigned] = $assets->partition(function (Asset $asset) {
return $asset->assigned_to;
});
session()->flashInput(['selected_assets' => $assigned->pluck('id')->values()->toArray()]);
}
$do_not_change = ['' => trans('general.do_not_change')];
$status_label_list = $do_not_change + Helper::statusLabelList();
return view('hardware/bulk-checkin', [
'statusLabel_list' => $status_label_list,
'removed_assets' => $notAssigned,
]);
}
/**
* Process Multiple Checkin Request
*/
public function storeCheckin(Request $request): RedirectResponse
{
$this->authorize('checkin', Asset::class);
if (! is_array($request->input('selected_assets'))) {
return redirect()->route('hardware.bulkcheckin.show')->withInput()->with('error', trans('admin/hardware/message.multi-checkin.no_assets_selected'));
}
$asset_ids = array_filter($request->input('selected_assets'));
$assets = Asset::findOrFail($asset_ids);
$checkin_at = date('Y-m-d H:i:s');
if ($request->filled('checkin_at') && $request->input('checkin_at') != date('Y-m-d')) {
$checkin_at = $request->input('checkin_at');
}
$errors = [];
$admin = auth()->user();
DB::transaction(function () use ($assets, $admin, $checkin_at, $request, &$errors) {
foreach ($assets as $asset) {
$this->authorize('checkin', $asset);
if (is_null($asset->assignedTo)) {
continue;
}
$target = $asset->assignedTo;
$originalValues = $asset->getRawOriginal();
$asset->expected_checkin = null;
$asset->assignedTo()->disassociate($asset);
$asset->accepted = null;
if ($request->filled('status_id')) {
$asset->status_id = $request->input('status_id');
}
$asset->location_id = $asset->rtd_location_id;
$asset->last_checkin = $checkin_at;
if ($request->boolean('checkin_licenses')) {
$asset->licenseseats->each(function (LicenseSeat $seat) {
$seat->update(['assigned_to' => null]);
});
}
CheckoutAcceptance::pending()->whereHasMorph('checkoutable', [Asset::class], function (Builder $query) use ($asset) {
$query->where('id', $asset->id);
})->get()->each->delete();
if ($asset->save()) {
if ($request->boolean('checkin_child_assets')) {
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
}
event(new CheckoutableCheckedIn($asset, $target, $admin, $request->input('note'), $checkin_at, $originalValues));
} else {
$errors = array_merge_recursive($errors, $asset->getErrors()->toArray());
}
}
});
if (! $errors) {
return redirect()->intended(route('hardware.index'))->with('success', trans_choice('admin/hardware/message.multi-checkin.success', count($asset_ids)));
}
return redirect()->route('hardware.bulkcheckin.show')->withInput()
->with('error', trans_choice('admin/hardware/message.multi-checkin.error', count($asset_ids)))
->withErrors($errors);
}
public function restore(Request $request): RedirectResponse
{
$this->authorize('update', Asset::class);
@@ -54,6 +54,7 @@ class GoogleAuthController extends Controller
Log::debug('Google user '.$socialUser->getEmail().' found in Snipe-IT');
$user->update([
'avatar' => $socialUser->avatar,
'last_login' => \Carbon::now(),
]);
Auth::login($user, true);
@@ -17,7 +17,7 @@ class BulkLicensesController extends Controller
$errors = [];
$success_count = 0;
foreach ($request->ids as $id) {
foreach ($request->input('ids', []) as $id) {
$license = License::find($id);
if (is_null($license)) {
@@ -13,6 +13,7 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
@@ -127,10 +128,45 @@ class LicenseCheckinController extends Controller
* @see LicenseCheckinController::create() method that provides the form view
* @since [v6.1.1]
*
* @return RedirectResponse
*
* @throws AuthorizationException
*/
public function bulkCheckinSelected(Request $request): RedirectResponse
{
$this->authorize('checkin', License::class);
$seatIds = $request->input('ids', []);
if (empty($seatIds)) {
return redirect()->back()->with('warning', trans('admin/licenses/general.bulk.checkin_selected.no_seats_selected'));
}
$seats = LicenseSeat::whereIn('id', $seatIds)
->where(function ($query) {
$query->whereNotNull('assigned_to')->orWhereNotNull('asset_id');
})
->with('license', 'user', 'asset')
->get();
$count = 0;
foreach ($seats as $seat) {
if (! $seat->license || ! Gate::allows('checkin', $seat->license)) {
continue;
}
$target = $seat->user ?? $seat->asset;
$seat->assigned_to = null;
$seat->asset_id = null;
if (! $seat->license->reassignable) {
$seat->unreassignable_seat = true;
}
if ($seat->save()) {
event(new CheckoutableCheckedIn($seat, $target, auth()->user(), null));
$count++;
}
}
return redirect()->back()->with('success', trans_choice('admin/licenses/general.bulk.checkin_selected.success', $count, ['count' => $count]));
}
public function bulkCheckin(Request $request, $licenseId)
{
@@ -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)
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(): View
{
$this->authorize('index', MaintenanceType::class);
return view('maintenance-types.index');
}
public function create(): View
{
$this->authorize('create', MaintenanceType::class);
return view('maintenance-types.edit')->with('item', new MaintenanceType);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.create.success'));
}
return redirect()->back()->withInput()->withErrors($type->getErrors());
}
public function edit(MaintenanceType $maintenanceType): View
{
$this->authorize('update', $maintenanceType);
return view('maintenance-types.edit')->with('item', $maintenanceType);
}
public function update(Request $request, MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.update.success'));
}
return redirect()->back()->withInput()->withErrors($maintenanceType->getErrors());
}
public function destroy(MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.delete.success'));
}
}
+44 -28
View File
@@ -2,11 +2,14 @@
namespace App\Http\Controllers;
use App\Enums\ActionType;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\UploadFileRequest;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use Carbon\Carbon;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -57,6 +60,7 @@ class MaintenancesController extends Controller
return view('maintenances/edit')
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('asset', $asset)
->with('item', new Maintenance);
}
@@ -82,6 +86,10 @@ class MaintenancesController extends Controller
// Loop through the selected assets
foreach ($assets as $asset) {
if (! Company::isCurrentUserHasAccess($asset)) {
continue;
}
$maintenance = new Maintenance;
$maintenance->supplier_id = $request->input('supplier_id');
$maintenance->is_warranty = $request->input('is_warranty');
@@ -92,20 +100,13 @@ class MaintenancesController extends Controller
// Save the asset maintenance data
$maintenance->asset_id = $asset->id;
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id') ?: auth()->id();
$maintenance->created_by = auth()->id();
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$request->handleImages($maintenance);
// Was the asset maintenance created?
@@ -141,6 +142,7 @@ class MaintenancesController extends Controller
->with('selected_assets', $maintenance->asset->pluck('id')->toArray())
->with('asset_ids', request()->input('asset_ids', []))
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('item', $maintenance);
}
@@ -169,28 +171,12 @@ class MaintenancesController extends Controller
$maintenance->cost = $request->input('cost');
$maintenance->notes = $request->input('notes');
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id');
$maintenance->url = $request->input('url');
// Todo - put this in a getter/setter?
if (($maintenance->completion_date == null)) {
if (($maintenance->asset_maintenance_time !== 0)
|| (! is_null($maintenance->asset_maintenance_time))
) {
$maintenance->asset_maintenance_time = null;
}
}
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$request->handleImages($maintenance);
if ($maintenance->save()) {
@@ -253,6 +239,36 @@ class MaintenancesController extends Controller
)->validate();
}
/**
* Mark a maintenance record as complete, logging who completed it and when.
*/
public function complete(Request $request, Maintenance $maintenance): RedirectResponse
{
$this->authorize('update', $maintenance->asset);
if ($maintenance->completed_at) {
return redirect()->back()
->with('warning', trans('admin/maintenances/form.already_complete'));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return redirect()->back()
->with('success', trans('admin/maintenances/message.complete.success'));
}
/**
* Delete an asset maintenance
*
+1
View File
@@ -30,6 +30,7 @@ class ModalController extends Controller
'kit-consumable',
'kit-accessory',
'location',
'maintenance-type',
'manufacturer',
'model',
'statuslabel',
+14 -9
View File
@@ -4,13 +4,15 @@ namespace App\Http\Controllers;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Maintenance;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class NotesController extends Controller
{
public function store(Request $request)
public function store(Request $request): RedirectResponse
{
$this->authorize('update', Asset::class);
@@ -19,13 +21,19 @@ class NotesController extends Controller
'note' => 'required|string|max:50000',
'type' => [
'required',
Rule::in(['asset']),
Rule::in(['asset', 'maintenance']),
],
]);
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
if ($validated['type'] === 'maintenance') {
$item = Maintenance::findOrFail($validated['id']);
$this->authorize('update', $item->asset);
$redirect = redirect()->route('maintenances.show', $validated['id']);
} else {
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
$redirect = redirect()->route('hardware.show', $validated['id']);
}
$logaction = new Actionlog;
$logaction->item_id = $item->id;
@@ -34,9 +42,6 @@ class NotesController extends Controller
$logaction->created_by = Auth::id();
$logaction->logaction('note added');
return redirect()
->route('hardware.show', $validated['id'])
->withFragment('history')
->with('success', trans('general.note_added'));
return $redirect->withFragment('notes')->with('success', trans('general.note_added'));
}
}
+1 -1
View File
@@ -424,7 +424,7 @@ class ReportsController extends Controller
$row[] = $license->remaincount();
$row[] = $license->expiration_date;
$row[] = $license->purchase_date;
$row[] = ($license->depreciation != '') ? '' : e($license->depreciation->name);
$row[] = ($license->depreciation != '') ? e($license->depreciation->name) : '';
$row[] = '"'.Helper::formatCurrencyOutput($license->purchase_cost).'"';
$rows[] = implode(',', $row);
@@ -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,11 +169,8 @@ class BulkUsersController extends Controller
$this->conditionallyAddItem('location_id')
->conditionallyAddItem('department_id')
->conditionallyAddItem('company_id')
->conditionallyAddItem('locale')
->conditionallyAddItem('remote')
->conditionallyAddItem('ldap_import')
->conditionallyAddItem('activated')
->conditionallyAddItem('display_name')
->conditionallyAddItem('start_date')
->conditionallyAddItem('end_date')
@@ -202,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;
}
@@ -235,11 +233,37 @@ class BulkUsersController extends Controller
->update(['location_id' => $this->update_array['location_id']]);
}
// Only sync groups if groups were selected
if ($request->filled('groups')) {
// 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) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$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) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$authFieldUpdate = [];
if ($request->filled('activated')) {
$authFieldUpdate['activated'] = $request->input('activated');
}
if ($request->filled('ldap_import')) {
$authFieldUpdate['ldap_import'] = $request->input('ldap_import');
}
if (! empty($authFieldUpdate)) {
$user->update($authFieldUpdate);
}
if ($request->filled('groups') && auth()->user()->isSuperUser()) {
$user->groups()->sync($request->input('groups'));
}
}
@@ -398,7 +422,7 @@ class BulkUsersController extends Controller
*/
public function merge(Request $request)
{
$this->authorize('update', User::class);
$this->authorize('delete', User::class);
if (config('app.lock_passwords')) {
return redirect()->route('users.index')->with('error', trans('general.feature_disabled'));
@@ -419,6 +443,10 @@ class BulkUsersController extends Controller
// Walk users
foreach ($users_to_merge as $user_to_merge) {
if (! auth()->user()->can('canEditAuthFields', $user_to_merge) || ! auth()->user()->can('editableOnDemo')) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
foreach ($user_to_merge->assets as $asset) {
Log::debug('Updating asset: '.$asset->asset_tag.' to '.$merge_into_user->id);
$asset->assigned_to = $request->input('merge_into_id');
@@ -461,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));
+18 -13
View File
@@ -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')) {
@@ -164,7 +165,7 @@ class UsersController extends Controller
}
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
if (auth()->user()->isSuperUser() && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
@@ -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);
@@ -311,12 +312,14 @@ class UsersController extends Controller
$user->password = bcrypt($request->input('password'));
}
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
targetUser: $user,
));
if ($request->has('permission')) {
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
targetUser: $user,
));
}
// Only save groups if the user is a superuser
if (auth()->user()->isSuperUser()) {
@@ -334,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'));
@@ -478,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);
@@ -596,10 +601,10 @@ 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')
->orderBy('created_at', 'DESC')
->chunk(500, function ($users) use ($handle) {
$formatter = new EscapeFormula('`');
@@ -618,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,
+20 -4
View File
@@ -19,6 +19,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* This controller handles all actions related to the ability for users
@@ -120,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
@@ -199,13 +201,23 @@ class ViewAssetsController extends Controller
$settings = Setting::getSettings();
if (($item_request = $item->isRequestedBy($user)) || $cancel_by_admin) {
$item->cancelRequest($requestingUser);
$is_admin = $user->isSuperUser() || $user->isAdmin();
if ($cancel_by_admin && ! $is_admin) {
return redirect()->back()->with('error', trans('general.insufficient_permissions'));
}
if (($item_request = $item->isRequestedBy($user)) || ($is_admin && $cancel_by_admin)) {
$item->cancelRequest($is_admin && $cancel_by_admin ? $requestingUser : null);
$data['item_quantity'] = ($item_request) ? $item_request->qty : 1;
$logaction->logaction(ActionType::RequestCanceled);
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
try {
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send request cancellation notification: '.$e->getMessage());
}
}
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
@@ -213,7 +225,11 @@ class ViewAssetsController extends Controller
$item->request();
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$logaction->logaction('requested');
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
try {
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send asset request notification: '.$e->getMessage());
}
}
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.success'));
+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);
}
}
+2 -2
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 = [];
@@ -51,7 +51,7 @@ class ItemImportRequest extends FormRequest
if (is_null($fieldValue)) {
$errorMessage = trans('validation.import_field_empty', ['fieldname' => $field]);
$this->errorCallback($import, $field, [$field => $errorMessage]);
$this->errorCallback($import, $field, [$field => [$errorMessage]]);
return $this->errors;
}
@@ -116,10 +116,10 @@ class ActionlogsTransformer
$clean_meta[$fieldname]['old'] = '************';
$clean_meta[$fieldname]['new'] = '************';
// Display the changes if the user is an admin or superadmin
if (Gate::allows('admin')) {
$clean_meta[$fieldname]['old'] = ($enc_old) ? unserialize($enc_old, ['allowed_classes' => false]) : '';
$clean_meta[$fieldname]['new'] = ($enc_new) ? unserialize($enc_new, ['allowed_classes' => false]) : '';
// Display the changes if the user has permission to view encrypted custom fields
if (Gate::allows('assets.view.encrypted_custom_fields')) {
$clean_meta[$fieldname]['old'] = ($enc_old) ? e(unserialize($enc_old, ['allowed_classes' => false])) : '';
$clean_meta[$fieldname]['new'] = ($enc_new) ? e(unserialize($enc_new, ['allowed_classes' => false])) : '';
}
}
@@ -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']);
@@ -388,6 +388,9 @@ class AssetsTransformer
$permissions_array['available_actions'] = [
'checkout' => false,
'checkin' => Gate::allows('checkin', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class),
],
];
$array += $permissions_array;
@@ -75,6 +75,9 @@ class CategoriesTransformer
$permissions_array['available_actions'] = [
'update' => Gate::allows('update', Category::class),
'delete' => $category->isDeletable(),
'bulk_selectable' => [
'delete' => $category->isDeletable(),
],
];
$array += $permissions_array;
@@ -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) ? [
@@ -70,6 +68,9 @@ class LicenseSeatsTransformer
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => Gate::allows('delete', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class) && ($seat->assigned_to || $seat->asset_id),
],
];
$array += $permissions_array;
@@ -66,7 +66,6 @@ class LicensesTransformer
'created_at' => Helper::getFormattedDateObject($license->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($license->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($license->deleted_at, 'datetime'),
'user_can_checkout' => (bool) ($license->free_seats_count > 0),
'disabled' => $license->isInactive(),
];
@@ -76,6 +75,7 @@ class LicensesTransformer
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => $license->isDeletable(),
'user_can_checkout' => (bool) (($license->free_seats_count - License::unReassignableCount($license)) > 0),
'bulk_selectable' => [
'delete' => $license->isDeletable(),
],
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Transformers;
use App\Helpers\Helper;
use App\Models\MaintenanceType;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class MaintenanceTypesTransformer
{
public function transformMaintenanceTypes(Collection $types, int $total): array
{
$array = [];
foreach ($types as $type) {
$array[] = self::transformMaintenanceType($type);
}
return (new DatatablesTransformer)->transformDatatables($array, $total);
}
public function transformMaintenanceType(MaintenanceType $type): array
{
return [
'id' => (int) $type->id,
'name' => e($type->name),
'created_at' => Helper::getFormattedDateObject($type->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($type->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($type->deleted_at, 'datetime'),
'available_actions' => [
'update' => Gate::allows('update', $type),
'delete' => $type->isDeletable(),
'restore' => Gate::allows('delete', $type),
],
];
}
}
@@ -82,6 +82,22 @@ class MaintenancesTransformer
'id' => (int) $assetmaintenance->adminuser->id,
'name' => e($assetmaintenance->adminuser->display_name),
] : null,
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => $assetmaintenance->checked_out_to_id ? [
'id' => (int) $assetmaintenance->checked_out_to_id,
'type' => $assetmaintenance->checked_out_to_type,
] : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
'is_warranty' => (bool) $assetmaintenance->is_warranty,
@@ -91,6 +107,7 @@ class MaintenancesTransformer
$permissions_array['available_actions'] = [
'update' => (Gate::allows('update', Asset::class) && ((($assetmaintenance->asset) && $assetmaintenance->asset->deleted_at == ''))) ? true : false,
'delete' => Gate::allows('delete', Asset::class),
'complete' => Gate::allows('update', Asset::class) && ! $assetmaintenance->completed_at,
];
$array += $permissions_array;
@@ -128,10 +145,23 @@ class MaintenancesTransformer
'supplier' => ($assetmaintenance->supplier) ? e($assetmaintenance->supplier?->name) : null,
'url' => ($assetmaintenance->url) ? e($assetmaintenance->url) : null,
'cost' => Helper::formatCurrencyOutput($assetmaintenance->cost),
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'asset_maintenance_type' => e($assetmaintenance->asset_maintenance_type),
'start_date' => Helper::getFormattedDateObject($assetmaintenance->start_date, 'date'),
'asset_maintenance_time' => $assetmaintenance->asset_maintenance_time,
'completion_date' => Helper::getFormattedDateObject($assetmaintenance->completion_date, 'date'),
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => ($assetmaintenance->checkedOutTo) ? e($assetmaintenance->checkedOutTo->display_name) : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_by' => ($assetmaintenance->adminuser) ? e($assetmaintenance->adminuser->display_name) : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
@@ -52,6 +52,9 @@ class ManufacturersTransformer
'update' => (($manufacturer->deleted_at == '') && (Gate::allows('update', Manufacturer::class))),
'restore' => (($manufacturer->deleted_at != '') && (Gate::allows('create', Manufacturer::class))),
'delete' => $manufacturer->isDeletable(),
'bulk_selectable' => [
'delete' => $manufacturer->isDeletable(),
],
];
$array += $permissions_array;
@@ -57,6 +57,9 @@ class SuppliersTransformer
$permissions_array['available_actions'] = [
'update' => Gate::allows('update', Supplier::class),
'delete' => (Gate::allows('delete', Supplier::class) && ($supplier->isDeletable())),
'bulk_selectable' => [
'delete' => (Gate::allows('delete', Supplier::class) && ($supplier->isDeletable())),
],
];
$array += $permissions_array;
+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) {
+6
View File
@@ -720,6 +720,12 @@ class Importer extends Component
$this->message_type = 'danger';
}
public function process(): void
{
$this->message = trans('general.token_expired');
$this->message_type = 'danger';
}
public function clearMessage()
{
$this->message = null;
+25 -28
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Token;
use Livewire\Component;
class OauthClients extends Component
@@ -50,11 +51,11 @@ class OauthClients extends Component
->get();
if ($clients->isNotEmpty()) {
$tokenCountsByClientId = DB::table('oauth_access_tokens')
$tokenCountsByClientId = Token::query()
->whereIn('client_id', $clients->pluck('id')->all())
->selectRaw('client_id, COUNT(*) as token_count')
->get(['client_id'])
->groupBy('client_id')
->pluck('token_count', 'client_id');
->map->count();
$clients->each(function ($client) use ($tokenCountsByClientId): void {
$client->setAttribute('associated_token_count', (int) ($tokenCountsByClientId[$client->id] ?? 0));
@@ -64,32 +65,28 @@ class OauthClients extends Component
$authorizedApplications = collect();
if ($this->showAuthorizedApplications()) {
$authorizedTokenSummary = DB::table('oauth_access_tokens as tokens')
->where('tokens.revoked', false)
->selectRaw('tokens.client_id')
->selectRaw('MAX(tokens.scopes) as scopes')
->selectRaw('MAX(tokens.created_at) as created_at')
->selectRaw('MAX(tokens.expires_at) as expires_at')
->groupBy('tokens.client_id');
$authorizedApplications = DB::table('oauth_clients as clients')
->joinSub($authorizedTokenSummary, 'token_summary', function ($join) {
$join->on('clients.id', '=', 'token_summary.client_id');
})
->leftJoin('users as creators', 'clients.user_id', '=', 'creators.id')
->select([
'clients.id as client_id',
'clients.name as client_name',
'clients.user_id as client_owner_id',
'creators.display_name as client_owner_display_name',
'creators.username as client_owner_username',
'creators.deleted_at as client_owner_deleted_at',
'token_summary.scopes',
'token_summary.created_at',
'token_summary.expires_at',
$authorizedApplications = Token::query()
->where('revoked', false)
->with([
'client',
'client.user' => fn ($q) => $q->withTrashed(),
])
->orderByDesc('token_summary.created_at')
->get();
->orderByDesc('created_at')
->get()
->unique('client_id')
->filter(fn ($token) => $token->client !== null)
->map(fn ($token) => (object) [
'client_id' => $token->client_id,
'client_name' => $token->client->name,
'client_owner_id' => $token->client->user_id,
'client_owner_display_name' => $token->client->user?->display_name,
'client_owner_username' => $token->client->user?->username,
'client_owner_deleted_at' => $token->client->user?->deleted_at,
'scopes' => $token->scopes,
'created_at' => $token->created_at,
'expires_at' => $token->expires_at,
])
->values();
}
return view('livewire.oauth-clients', [
+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');
@@ -0,0 +1,117 @@
<?php
namespace App\Models\Builders;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class MaintenanceQueryBuilder extends Builder
{
public function active(): static
{
return $this->whereNull('maintenances.completed_at');
}
public function completed(): static
{
return $this->whereNotNull('maintenances.completed_at');
}
public function dueForCompletion(Setting $settings): static
{
$interval = (int) ($settings->audit_warning_days ?? 0);
$today = Carbon::now();
return $this->whereNotNull('maintenances.completion_date')
->whereNull('maintenances.completed_at')
->whereBetween('maintenances.completion_date', [
$today->format('Y-m-d'),
$today->copy()->addDays($interval)->format('Y-m-d'),
]);
}
public function overdueForCompletion(): static
{
return $this->whereNotNull('maintenances.completion_date')
->whereNull('maintenances.completed_at')
->where('maintenances.completion_date', '<', Carbon::now()->format('Y-m-d'));
}
public function dueOrOverdueForCompletion(Setting $settings): static
{
return $this->where(fn ($q) => $q->overdueForCompletion())
->orWhere(fn ($q) => $q->dueForCompletion($settings));
}
public function orderBySupplier(string $order): static
{
return $this->leftJoin('suppliers as suppliers_maintenances', 'maintenances.supplier_id', '=', 'suppliers_maintenances.id')
->orderBy('suppliers_maintenances.name', $order);
}
public function orderByTag(string $order): static
{
return $this->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.asset_tag', $order);
}
public function orderByAssetName(string $order): static
{
return $this->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.name', $order);
}
public function orderByAssetSerial(string $order): static
{
return $this->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.serial', $order);
}
public function orderStatusName(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('status_labels as maintained_asset_status', 'maintained_asset_status.id', '=', 'maintained_asset.status_id')
->orderBy('maintained_asset_status.name', $order);
}
public function orderLocationName(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('locations as maintained_asset_location', 'maintained_asset_location.id', '=', 'maintained_asset.location_id')
->orderBy('maintained_asset_location.name', $order);
}
public function orderByCreatedBy(string $order): static
{
return $this->leftJoin('users as admin_sort', 'maintenances.created_by', '=', 'admin_sort.id')
->select('maintenances.*')
->orderBy('admin_sort.first_name', $order)
->orderBy('admin_sort.last_name', $order);
}
public function orderByAssetModelName(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.name', $order);
}
public function orderByAssetModelNumber(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.model_number', $order);
}
public function orderByMaintenanceType(string $order): static
{
return $this->leftJoin('maintenance_types as maintenance_type_sort', 'maintenances.maintenance_type_id', '=', 'maintenance_type_sort.id')
->orderBy('maintenance_type_sort.name', $order);
}
public function orderByCompletedAt(string $order): static
{
return $this->orderBy('maintenances.completed_at', $order);
}
}
+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);
}
}
/**
+1 -1
View File
@@ -690,7 +690,7 @@ abstract class Label
// Find one
if ($name !== null) {
return static::find()
->sole(
->first(
function ($label) use ($name) {
return $label->getName() == $name;
}
+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();
}
+40 -108
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Helpers\Helper;
use App\Models\Builders\MaintenanceQueryBuilder;
use App\Models\Traits\CompanyableChildTrait;
use App\Models\Traits\HasUploads;
use App\Models\Traits\Loggable;
@@ -12,7 +13,6 @@ use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Gate;
use Watson\Validating\ValidatingTrait;
@@ -39,7 +39,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
protected $rules = [
'asset_id' => 'required|integer',
'supplier_id' => 'nullable|integer',
'asset_maintenance_type' => 'required',
'maintenance_type_id' => 'required|integer|exists:maintenance_types,id',
'name' => 'required|max:100',
'is_warranty' => 'boolean',
'start_date' => 'required|date_format:Y-m-d',
@@ -47,6 +47,8 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'notes' => 'string|nullable',
'cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'url' => 'nullable|url|max:255',
'responsible_party_id' => 'nullable|integer|exists:users,id',
'completed_by' => 'nullable|integer|exists:users,id',
];
/**
@@ -59,6 +61,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'asset_id',
'supplier_id',
'asset_maintenance_type',
'maintenance_type_id',
'is_warranty',
'start_date',
'completion_date',
@@ -66,6 +69,11 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'notes',
'cost',
'url',
'checked_out_to_id',
'checked_out_to_type',
'responsible_party_id',
'completed_at',
'completed_by',
];
use Searchable;
@@ -79,7 +87,6 @@ class Maintenance extends SnipeModel implements ICompanyableChild
[
'name',
'notes',
'asset_maintenance_type',
'cost',
'start_date',
'completion_date',
@@ -97,6 +104,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'asset.status' => ['name'],
'supplier' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
'maintenanceType' => ['name'],
];
public function getCompanyableParents()
@@ -204,116 +212,40 @@ class Maintenance extends SnipeModel implements ICompanyableChild
->withTrashed();
}
public function maintenanceType()
{
return $this->belongsTo(MaintenanceType::class, 'maintenance_type_id');
}
public function responsibleParty()
{
return $this->belongsTo(User::class, 'responsible_party_id')
->withTrashed();
}
public function completedByUser()
{
return $this->belongsTo(User::class, 'completed_by')
->withTrashed();
}
public function checkedOutTo()
{
return $this->morphTo('checked_out_to');
}
public function journal()
{
return $this->assetlog()->where('action_type', '=', 'note added');
}
public function getDisplayNameAttribute()
{
return $this->name;
}
/**
* -----------------------------------------------
* BEGIN QUERY SCOPES
* -----------------------------------------------
**/
/**
* Query builder scope to order on a supplier
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderBySupplier($query, $order)
public function newEloquentBuilder($query): MaintenanceQueryBuilder
{
return $query->leftJoin('suppliers as suppliers_maintenances', 'maintenances.supplier_id', '=', 'suppliers_maintenances.id')
->orderBy('suppliers_maintenances.name', $order);
}
/**
* Query builder scope to order on asset tag
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderByTag($query, $order)
{
return $query->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.asset_tag', $order);
}
/**
* Query builder scope to order on asset tag
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderByAssetName($query, $order)
{
return $query->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.name', $order);
}
/**
* Query builder scope to order on serial
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderByAssetSerial($query, $order)
{
return $query->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.serial', $order);
}
/**
* Query builder scope to order on status label name
*
* @param Builder $query Query builder instance
* @param text $order Order
* @return Builder Modified query builder
*/
public function scopeOrderStatusName($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('status_labels as maintained_asset_status', 'maintained_asset_status.id', '=', 'maintained_asset.status_id')
->orderBy('maintained_asset_status.name', $order);
}
/**
* Query builder scope to order on status label name
*
* @param Builder $query Query builder instance
* @param text $order Order
* @return Builder Modified query builder
*/
public function scopeOrderLocationName($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('locations as maintained_asset_location', 'maintained_asset_location.id', '=', 'maintained_asset.location_id')
->orderBy('maintained_asset_location.name', $order);
}
/**
* Query builder scope to order on the user that created it
*/
public function scopeOrderByCreatedBy($query, $order)
{
return $query->leftJoin('users as admin_sort', 'maintenances.created_by', '=', 'admin_sort.id')->select('maintenances.*')->orderBy('admin_sort.first_name', $order)->orderBy('admin_sort.last_name', $order);
}
public function scopeOrderByAssetModelName($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.name', $order);
}
public function scopeOrderByAssetModelNumber($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.model_number', $order);
return new MaintenanceQueryBuilder($query);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Gate;
use Watson\Validating\ValidatingTrait;
class MaintenanceType extends SnipeModel
{
use HasFactory;
use Presentable;
use SoftDeletes;
use ValidatingTrait;
protected $table = 'maintenance_types';
protected $rules = [
'name' => 'required|max:100|unique:maintenance_types,name,NULL,id,deleted_at,NULL',
];
protected $injectUniqueIdentifier = true;
protected $fillable = ['name'];
public function isDeletable(): bool
{
return Gate::allows('delete', $this)
&& ($this->deleted_at == '');
}
public function maintenances()
{
return $this->hasMany(Maintenance::class, 'maintenance_type_id');
}
public function getDisplayNameAttribute(): string
{
return $this->name;
}
}
+7 -4
View File
@@ -142,10 +142,11 @@ class SnipeSCIMConfig
}
},
(new class('id', null) extends Constant // TODO - this 'id' is in the same namespace for objects OR groups?
{protected function doRead(&$object, $attributes = [])
{
return (string) $object->id;
}
protected function doRead(&$object, $attributes = [])
{
return (string) $object->id;
}
public function remove($value, &$object, $path = null)
{
@@ -385,7 +386,9 @@ class SnipeSCIMConfig
{
\Log::debug('What type of value is value? '.gettype($value));
$manager_id = null;
if (is_scalar($value)) {
if (is_null($value)) {
// nothing to do
} elseif (is_scalar($value)) {
\Log::debug('Weird Microsoft mode - set manager to the $value and move on with life?');
$manager_id = $value;
} elseif (array_key_exists('$ref', $value)) {
+1 -1
View File
@@ -53,7 +53,7 @@ trait Searchable
*/
public function scopeTextSearch($query, $search)
{
$preparedSearch = $this->prepareSearchInput((string) $search);
$preparedSearch = $this->prepareSearchInput(is_array($search) ? implode(' ', $search) : (string) $search);
$terms = $preparedSearch['terms'];
$filters = $preparedSearch['filters'];
$filterOperator = $preparedSearch['filter_operator'];
+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.
*
+3 -3
View File
@@ -35,12 +35,12 @@ class AssetObserver
$same_checkin_counter = (($attributes['checkin_counter'] == $attributesOriginal['checkin_counter']));
}
// If the asset isn't being checked out or audited, log the update.
// (Those other actions already create log entries.)
// If the asset isn't being checked out, log the update.
// (Checkout/checkin/audit actions already create their own log entries; the audit
// path uses unsetEventDispatcher() so it never reaches this observer.)
if (array_key_exists('assigned_to', $attributes) && array_key_exists('assigned_to', $attributesOriginal)
&& ($attributes['assigned_to'] == $attributesOriginal['assigned_to'])
&& ($same_checkout_counter) && ($same_checkin_counter)
&& ((isset($attributes['next_audit_date']) ? $attributes['next_audit_date'] : null) == (isset($attributesOriginal['next_audit_date']) ? $attributesOriginal['next_audit_date'] : null))
&& ($attributes['last_checkout'] == $attributesOriginal['last_checkout']) && (! $restoring_or_deleting)) {
$changed = [];
+26
View File
@@ -5,9 +5,23 @@ namespace App\Observers;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
class MaintenanceObserver
{
/**
* Capture the asset's current checkout state before the maintenance record is saved.
*/
public function creating(Maintenance $maintenance): void
{
if ($maintenance->asset_id && $asset = Asset::find($maintenance->asset_id)) {
$maintenance->checked_out_to_id = $asset->assigned_to;
$maintenance->checked_out_to_type = $asset->assigned_type;
}
$this->syncLegacyMaintenanceType($maintenance);
}
/**
* Listen to the User created event.
*
@@ -15,6 +29,8 @@ class MaintenanceObserver
*/
public function updating(Maintenance $maintenance)
{
$this->syncLegacyMaintenanceType($maintenance);
$changed = [];
foreach ($maintenance->getRawOriginal() as $key => $value) {
@@ -47,6 +63,16 @@ class MaintenanceObserver
$logAction->logaction('update');
}
private function syncLegacyMaintenanceType(Maintenance $maintenance): void
{
if ($maintenance->maintenance_type_id && ! $maintenance->asset_maintenance_type) {
$type = MaintenanceType::find($maintenance->maintenance_type_id);
if ($type) {
$maintenance->asset_maintenance_type = $type->name;
}
}
}
/**
* Listen to the Component created event when
* a new component is created.
+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;
}
}
+10 -1
View File
@@ -37,8 +37,12 @@ final class MaintenancePolicy
* Determine whether the user can view a specific maintenance record.
* Allowed if the user can edit the associated asset.
*/
public function view(User $user, Maintenance $maintenance): bool
public function view(User $user, ?Maintenance $maintenance = null): bool
{
if (is_null($maintenance)) {
return $user->hasAccess('assets.view');
}
return Gate::allows('update', $maintenance->asset);
}
@@ -94,4 +98,9 @@ final class MaintenancePolicy
|| Gate::allows('view', $maintenance)
|| $user->hasAccess('activity.view');
}
public function journal(User $user, Maintenance $maintenance): bool
{
return Gate::allows('view', $maintenance) || $user->hasAccess('activity.view');
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Policies;
class MaintenanceTypePolicy extends SnipePermissionsPolicy
{
protected function columnName()
{
return 'maintenances';
}
}
+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,
+9
View File
@@ -461,6 +461,15 @@ class AssetPresenter extends Presenter
}
}
public function formattedNameLink()
{
if (auth()->user()->can('view', ['\App\Models\Asset', $this])) {
return '<a href="'.route('hardware.show', e($this->id)).'" class="'.(($this->deleted_at != '') ? 'deleted' : '').'">'.e($this->display_name).'</a>';
}
return '<span class="'.(($this->deleted_at != '') ? 'deleted' : '').'">'.e($this->display_name).'</span>';
}
public function modelUrl()
{
if ($this->model->model) {
+1
View File
@@ -18,6 +18,7 @@ class CategoryPresenter extends Presenter
[
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
+58 -33
View File
@@ -240,41 +240,53 @@ class LicensePresenter extends Presenter
*
* @return string
*/
public static function dataTableLayoutSeats()
public static function dataTableLayoutSeats(bool $withCheckbox = true)
{
$layout = [
$layout = [];
if ($withCheckbox) {
$layout[] = [
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
];
}
$layout = array_merge($layout, [[
'field' => 'id',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.id'),
'visible' => false,
], [
'field' => 'assigned_user',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/licenses/general.user'),
'visible' => true,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'assigned_user.email',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/users/table.email'),
'visible' => true,
'formatter' => 'emailFormatter',
],
[
'field' => 'id',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.id'),
'visible' => false,
], [
'field' => 'assigned_user',
'field' => 'assigned_user.companies',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/licenses/general.user'),
'title' => trans('general.companies'),
'visible' => true,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'assigned_user.email',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/users/table.email'),
'visible' => true,
'formatter' => 'emailFormatter',
],
[
'field' => 'assigned_user.company',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('general.company'),
'visible' => true,
'formatter' => 'companiesLinkObjFormatter',
'formatter' => 'companiesArrayLinkFormatter',
],
[
'field' => 'assigned_user.department',
@@ -328,14 +340,27 @@ class LicensePresenter extends Presenter
'printIgnore' => true,
'class' => 'hidden-print',
],
];
]);
return json_encode($layout);
}
public static function dataTableLayoutSeatsCheckedOutToAssets()
public static function dataTableLayoutSeatsCheckedOutToAssets($hide_fields = [])
{
$layout = [
$layout = [];
if (! in_array('checkbox', $hide_fields)) {
$layout[] = [
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
];
}
$layout = array_merge($layout, [
[
'field' => 'id',
'searchable' => false,
@@ -386,7 +411,7 @@ class LicensePresenter extends Presenter
'printIgnore' => true,
'class' => 'hidden-print',
],
];
]);
return json_encode($layout);
}
@@ -0,0 +1,54 @@
<?php
namespace App\Presenters;
class MaintenanceTypePresenter extends Presenter
{
public static function dataTableLayout(): string
{
$layout = [
[
'field' => 'id',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.id'),
'visible' => false,
], [
'field' => 'name',
'searchable' => true,
'sortable' => true,
'switchable' => false,
'title' => trans('general.name'),
'visible' => true,
], [
'field' => 'created_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.created_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'updated_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.updated_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'actions',
'searchable' => false,
'sortable' => false,
'switchable' => false,
'title' => trans('table.actions'),
'visible' => true,
'formatter' => 'maintenanceTypesActionsFormatter',
'printIgnore' => true,
],
];
return json_encode($layout);
}
}
+69 -3
View File
@@ -88,7 +88,7 @@ class MaintenancesPresenter extends Presenter
'switchable' => true,
'title' => trans('general.model_no'),
'visible' => true,
],[
], [
'field' => 'assigned_to',
'searchable' => true,
'sortable' => true,
@@ -111,10 +111,43 @@ class MaintenancesPresenter extends Presenter
'title' => trans('general.location'),
'formatter' => 'locationsLinkObjFormatter',
], [
'field' => 'asset_maintenance_type',
'field' => 'maintenance_type',
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.asset_maintenance_type'),
'visible' => true,
], [
'field' => 'responsible_party',
'searchable' => true,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.responsible_party'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'checked_out_to_at_creation',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.checked_out_to_at_creation'),
'visible' => false,
], [
'field' => 'completed_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'completed_by',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_by'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'start_date',
'searchable' => true,
@@ -274,10 +307,27 @@ class MaintenancesPresenter extends Presenter
'sortable' => true,
'title' => trans('general.location'),
], [
'field' => 'asset_maintenance_type',
'field' => 'maintenance_type',
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.asset_maintenance_type'),
'visible' => true,
], [
'field' => 'responsible_party',
'searchable' => true,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.responsible_party'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'checked_out_to_at_creation',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.checked_out_to_at_creation'),
'visible' => false,
], [
'field' => 'start_date',
'searchable' => true,
@@ -295,6 +345,22 @@ class MaintenancesPresenter extends Presenter
'searchable' => true,
'sortable' => true,
'title' => trans('admin/maintenances/form.asset_maintenance_time'),
], [
'field' => 'completed_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'completed_by',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_by'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'url',
'searchable' => true,
+1
View File
@@ -18,6 +18,7 @@ class ManufacturerPresenter extends Presenter
[
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
+1
View File
@@ -16,6 +16,7 @@ class SupplierPresenter extends Presenter
[
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
+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',
+8 -4
View File
@@ -16,6 +16,7 @@ use App\Models\Depreciation;
use App\Models\License;
use App\Models\Location;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Manufacturer;
use App\Models\PredefinedKit;
use App\Models\Statuslabel;
@@ -35,12 +36,13 @@ use App\Policies\DepreciationPolicy;
use App\Policies\LicensePolicy;
use App\Policies\LocationPolicy;
use App\Policies\MaintenancePolicy;
use App\Policies\MaintenanceTypePolicy;
use App\Policies\ManufacturerPolicy;
use App\Policies\PredefinedKitPolicy;
use App\Policies\StatuslabelPolicy;
use App\Policies\SupplierPolicy;
use App\Policies\UserPolicy;
use Carbon\Carbon;
use Carbon\CarbonInterval;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Laravel\Passport\Console\ClientCommand;
@@ -71,6 +73,7 @@ class AuthServiceProvider extends ServiceProvider
License::class => LicensePolicy::class,
Location::class => LocationPolicy::class,
Maintenance::class => MaintenancePolicy::class,
MaintenanceType::class => MaintenanceTypePolicy::class,
PredefinedKit::class => PredefinedKitPolicy::class,
Statuslabel::class => StatuslabelPolicy::class,
Supplier::class => SupplierPolicy::class,
@@ -93,9 +96,10 @@ class AuthServiceProvider extends ServiceProvider
]);
$this->registerPolicies();
Passport::tokensExpireIn(Carbon::now()->addYears((int) config('passport.expiration_years')));
Passport::refreshTokensExpireIn(Carbon::now()->addYears((int) config('passport.expiration_years')));
Passport::personalAccessTokensExpireIn(Carbon::now()->addYears((int) config('passport.expiration_years')));
$expirationYears = (int) config('passport.expiration_years');
Passport::tokensExpireIn(CarbonInterval::years($expirationYears));
Passport::refreshTokensExpireIn(CarbonInterval::years($expirationYears));
Passport::personalAccessTokensExpireIn(CarbonInterval::years($expirationYears));
Passport::cookie(config('passport.cookie_name'));
+12 -3
View File
@@ -356,9 +356,18 @@ class BreadcrumbsServiceProvider extends ServiceProvider
/**
* Maintenances Breadcrumbs
*/
Breadcrumbs::for('maintenances.index', fn (Trail $trail) => $trail->parent('hardware.index', route('hardware.index'))
->push(trans('general.maintenances'), route('maintenances.index'))
);
Breadcrumbs::for('maintenances.index', function (Trail $trail) {
$trail->parent('hardware.index', route('hardware.index'))
->push(trans('general.maintenances'), route('maintenances.index'));
if (request()->input('upcoming_status') === 'due') {
$trail->push(trans('admin/maintenances/general.due'));
} elseif (request()->input('upcoming_status') === 'overdue') {
$trail->push(trans('admin/maintenances/general.overdue'));
} elseif (request()->input('completed') === 'true') {
$trail->push(trans('admin/maintenances/general.completed'));
}
});
Breadcrumbs::for('maintenances.create', fn (Trail $trail) => $trail->parent('maintenances.index', route('maintenances.index'))
->push(trans('general.create'), route('maintenances.create'))
+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;
}
}
+3 -2
View File
@@ -4,17 +4,18 @@ namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class ValidJson implements ValidationRule
{
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!json_validate($value)) {
if (! is_string($value) || ! json_validate($value)) {
$fail(trans('validation.json'));
}
}
+2 -4
View File
@@ -19,6 +19,7 @@
"require": {
"php": "^8.2",
"ext-curl": "*",
"ext-exif": "*",
"ext-fileinfo": "*",
"ext-iconv": "*",
"ext-json": "*",
@@ -31,7 +32,6 @@
"doctrine/dbal": "^3.1",
"doctrine/instantiator": "^1.3",
"eduardokum/laravel-mail-auto-embed": "^2.0",
"elibyy/tcpdf-laravel": "^11.5",
"enshrined/svg-sanitize": "^0.22.0",
"erusev/parsedown": "^1.7",
"fakerphp/faker": "^1.24",
@@ -86,9 +86,7 @@
"nunomaduro/phpinsights": "^2.11",
"php-mock/php-mock-phpunit": "^2.10",
"phpunit/phpunit": "^11.0",
"squizlabs/php_codesniffer": "^3.5",
"symfony/css-selector": "^4.4",
"symfony/dom-crawler": "^4.4"
"squizlabs/php_codesniffer": "^3.5"
},
"extra": {
"laravel": {
Generated
+943 -1115
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -12,7 +12,6 @@ use App\Providers\RouteServiceProvider;
use App\Providers\SamlServiceProvider;
use App\Providers\SettingsServiceProvider;
use App\Providers\SnipeTranslationServiceProvider;
use Elibyy\TCPDF\Facades\TCPDF;
use Illuminate\Auth\AuthServiceProvider;
use Illuminate\Auth\Passwords\PasswordResetServiceProvider;
use Illuminate\Broadcasting\BroadcastServiceProvider;
@@ -400,8 +399,6 @@ return [
DumbPasswordServiceProvider::class,
Eduardokum\LaravelMailAutoEmbed\ServiceProvider::class,
SocialiteServiceProvider::class,
Elibyy\TCPDF\ServiceProvider::class,
/*
* Application Service Providers...
*/
@@ -456,7 +453,6 @@ return [
'Mail' => Mail::class,
'Notification' => Notification::class,
'Password' => Password::class,
'PDF' => TCPDF::class,
'Queue' => Queue::class,
'Redirect' => Redirect::class,
'Redis' => Redis::class,
+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
+8 -7
View File
@@ -1,10 +1,11 @@
<?php
return array(
'app_version' => 'v8.5.0',
'full_app_version' => 'v8.5.0 - build 22652-g80d1bf6a7a',
'build_version' => '22652',
return [
'app_version' => 'v8.5.1-pre',
'full_app_version' => 'v8.5.1-pre - build 22809-g86245ad4ae',
'build_version' => '22809',
'prerelease_version' => '',
'hash_version' => 'g80d1bf6a7a',
'full_hash' => 'v8.5.0-467-g80d1bf6a7a',
'hash_version' => 'g86245ad4ae',
'full_hash' => 'v8.5.1-pre-185-g86245ad4ae',
'branch' => 'develop',
);
];
+5 -1
View File
@@ -4,6 +4,7 @@ namespace Database\Factories;
use App\Models\Asset;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -24,10 +25,13 @@ class MaintenanceFactory extends Factory
*/
public function definition()
{
$maintenanceType = MaintenanceType::factory()->create();
return [
'asset_id' => Asset::factory()->laptopZenbook(),
'supplier_id' => Supplier::factory(),
'asset_maintenance_type' => $this->faker->randomElement(['maintenance', 'repair', 'upgrade']),
'maintenance_type_id' => $maintenanceType->id,
'asset_maintenance_type' => $maintenanceType->name,
'name' => $this->faker->sentence(3),
'start_date' => $this->faker->date(),
'is_warranty' => $this->faker->boolean(),
@@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use App\Models\MaintenanceType;
use Illuminate\Database\Eloquent\Factories\Factory;
class MaintenanceTypeFactory extends Factory
{
protected $model = MaintenanceType::class;
public function definition(): array
{
return [
'name' => $this->faker->unique()->words(2, true),
];
}
}
+10
View File
@@ -180,6 +180,11 @@ class UserFactory extends Factory
return $this->appendPermission(['assets.view.requestable' => '1']);
}
public function viewEncryptedCustomFields()
{
return $this->appendPermission(['assets.view.encrypted_custom_fields' => '1']);
}
public function deleteAssetModels()
{
return $this->appendPermission(['models.delete' => '1']);
@@ -285,6 +290,11 @@ class UserFactory extends Factory
return $this->appendPermission(['licenses.checkout' => '1']);
}
public function checkinLicenses()
{
return $this->appendPermission(['licenses.checkin' => '1']);
}
public function viewKeysLicenses()
{
return $this->appendPermission(['licenses.keys' => '1']);
@@ -35,11 +35,15 @@ return new class extends Migration
*/
private function updateAssetAuditLogs(string $driver): void
{
$prefix = DB::getTablePrefix();
$actionLogs = $prefix.'action_logs';
$assets = $prefix.'assets';
if ($driver === 'mysql' || $driver === 'mariadb') {
// MySQL/MariaDB supports UPDATE ... JOIN directly
DB::statement('
UPDATE action_logs al
INNER JOIN assets src
DB::statement("
UPDATE {$actionLogs} al
INNER JOIN {$assets} src
ON src.id = al.item_id
AND src.company_id IS NOT NULL
SET al.company_id = src.company_id
@@ -47,15 +51,15 @@ return new class extends Migration
AND al.item_type = ?
AND al.company_id IS NULL
AND al.deleted_at IS NULL
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
", [self::AUDIT_ACTION, self::ASSET_CLASS]);
} else {
// SQLite / PostgreSQL: use a correlated subquery update
DB::statement('
UPDATE action_logs
DB::statement("
UPDATE {$actionLogs}
SET company_id = (
SELECT src.company_id
FROM assets src
WHERE src.id = action_logs.item_id
FROM {$assets} src
WHERE src.id = {$actionLogs}.item_id
AND src.company_id IS NOT NULL
LIMIT 1
)
@@ -64,11 +68,11 @@ return new class extends Migration
AND company_id IS NULL
AND deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM assets src2
WHERE src2.id = action_logs.item_id
SELECT 1 FROM {$assets} src2
WHERE src2.id = {$actionLogs}.item_id
AND src2.company_id IS NOT NULL
)
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
", [self::AUDIT_ACTION, self::ASSET_CLASS]);
}
}
};
@@ -0,0 +1,79 @@
<?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
{
// Create maintenance_types lookup table
Schema::create('maintenance_types', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->unsignedBigInteger('created_by')->nullable();
$table->softDeletes();
$table->timestamps();
});
// Seed with the 8 built-in types
$now = now();
$types = [
'Maintenance',
'Repair',
'Upgrade',
'PAT Test',
'Calibration',
'Software Support',
'Hardware Support',
'Configuration Change',
];
foreach ($types as $name) {
DB::table('maintenance_types')->insert([
'name' => $name,
'created_at' => $now,
'updated_at' => $now,
]);
}
// Add new tracking columns and the maintenance_type FK to maintenances
Schema::table('maintenances', function (Blueprint $table) {
$table->unsignedBigInteger('maintenance_type_id')->nullable();
$table->unsignedBigInteger('checked_out_to_id')->nullable();
$table->string('checked_out_to_type')->nullable();
$table->unsignedBigInteger('responsible_party_id')->nullable();
$table->timestamp('completed_at')->nullable();
$table->unsignedBigInteger('completed_by')->nullable();
});
// Map existing asset_maintenance_type strings to the new FK.
// The stored values are the same strings we just seeded into maintenance_types.
$types = DB::table('maintenance_types')->pluck('id', 'name');
foreach ($types as $name => $id) {
DB::table('maintenances')
->whereNull('maintenance_type_id')
->where('asset_maintenance_type', $name)
->update(['maintenance_type_id' => $id]);
}
}
public function down(): void
{
Schema::table('maintenances', function (Blueprint $table) {
$table->dropColumn([
'maintenance_type_id',
'checked_out_to_id',
'checked_out_to_type',
'responsible_party_id',
'completed_at',
'completed_by',
]);
});
Schema::dropIfExists('maintenance_types');
}
};
@@ -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);

Some files were not shown because too many files have changed in this diff Show More