Compare commits

...

474 Commits

Author SHA1 Message Date
snipe 90c8689596 Prod assets 2026-05-12 19:43:34 +01:00
snipe 161d7e1c2b Dev assets 2026-05-12 19:42:20 +01:00
snipe 8627032c4f Bumped version to 8.5.0 2026-05-12 19:41:10 +01:00
snipe 5bead4fbcc Pint :( 2026-05-12 19:30:54 +01:00
snipe ef44ba5f97 Updated languages 2026-05-12 19:29:57 +01:00
snipe 2dc0ec9e7e Bumped php_max_major_minor for new 8.5 support 2026-05-12 19:27:50 +01:00
snipe afd435e895 Bumped php max requirement 2026-05-12 15:23:15 +01:00
snipe 80d1bf6a7a Better manufacturer check again :( 2026-05-12 15:12:38 +01:00
snipe 737f3ef3db Skip lookup URL if manufacturer was deleted 2026-05-12 15:07:55 +01:00
snipe d179f47274 Merge pull request #19007 from uberbrady/reintroduce_scim_logging
Change branch of home-forked SCIM server to re-introduce logging
2026-05-12 14:51:11 +01:00
snipe 1832d95371 Merge pull request #19009 from grokability/bulk-delete-licenses
🎥 Added bulk deletion of licenses
2026-05-12 13:55:37 +01:00
snipe a614f986f0 Added test for the fix I made in the prior commit 2026-05-12 13:07:16 +01:00
snipe f398a59d26 Fixed bug on API delete 2026-05-12 13:05:15 +01:00
snipe c8bd104268 Added comment for clarity 2026-05-12 13:00:51 +01:00
snipe 7f01bd4c56 Merge pull request #19008 from uberbrady/more_exception_handling_in_scim
Widen exception scope in emails; similar treatment for phones
2026-05-12 12:57:06 +01:00
Brady Wetherington 6c3c7fdf49 Widen exception scope in emails; similar treatment for phones 2026-05-12 12:43:53 +01:00
snipe bdc8fc8d4a Eager load license seat relations to avoid n+1 2026-05-12 12:41:45 +01:00
snipe 41be127489 Added bulk license action 2026-05-12 12:41:30 +01:00
snipe fdfae9593d Use isDeletable for delete ability status 2026-05-12 12:41:08 +01:00
snipe 6a21eb53c9 Fixed return type 2026-05-12 12:40:29 +01:00
snipe efde2b4672 Added checknbox to presneter 2026-05-12 12:40:08 +01:00
snipe daaa26cbf4 Added new text strings 2026-05-12 12:34:40 +01:00
snipe 3e7441562c Added bulk action menu 2026-05-12 12:34:31 +01:00
snipe 7b53fa5245 Added bulk delete route 2026-05-12 12:34:16 +01:00
snipe d35d46f5b4 Added tests 2026-05-12 12:34:03 +01:00
Brady Wetherington f4772a9cad Change branch of home-forked SCIM server to re-introduce logging 2026-05-12 12:26:37 +01:00
snipe 4c1bb7e0ac Merge remote-tracking branch 'origin/master' into develop 2026-05-11 19:45:01 +01:00
snipe 762ea9b4db One more try I guess 2026-05-11 19:44:41 +01:00
snipe b16970a61e Merge pull request #18629 from Godmartinz/update-print-invtentory-view-with-assigned2assets
Update print inventory view with indirect assignments table
2026-05-11 18:27:25 +01:00
snipe dadb9bd81e Merge remote-tracking branch 'origin/develop' 2026-05-11 16:12:03 +01:00
snipe 13dc7de660 Add enter to submit advanced search modal 2026-05-11 16:11:51 +01:00
snipe 003ea36e18 Merge remote-tracking branch 'origin/develop' 2026-05-11 16:04:10 +01:00
snipe f4bd2a68c9 Fix for compound is:not_null 2026-05-11 16:03:59 +01:00
snipe be4e75d4f7 Merge remote-tracking branch 'origin/develop' 2026-05-11 15:25:56 +01:00
snipe 538c21ce1e Merge pull request #19002 from grokability/fixed-crash-on-checkout-outside-of-company-id
Fixed crash on checkout outside of company via API
2026-05-11 15:25:27 +01:00
snipe 626cd6cb2e Fixed #18989 - better wrapping for auth self.profile checks 2026-05-11 15:25:03 +01:00
snipe 2a56f6573d Wrap the checkout in a transaction and add tests 2026-05-11 15:07:25 +01:00
snipe 6ee2dc1cd6 Merge pull request #18998 from uberbrady/fix_scim_error_email
Don't 500 on malformed emails input
2026-05-11 14:59:56 +01:00
snipe 3fcde8bd16 Prevent crash when trying to check out a component from another company if FMCS is on 2026-05-11 14:43:54 +01:00
snipe e2ff7a7bc7 Merge pull request #19000 from grokability/reports-index
🎥 Added reports index
2026-05-11 14:37:14 +01:00
snipe c7efd16517 Fixed progressbar color 2026-05-11 14:25:25 +01:00
snipe f2907f04d9 Added nicer border 2026-05-11 14:19:10 +01:00
snipe 7d98c267d5 More formatting tweaks 2026-05-11 14:13:16 +01:00
snipe 5bc6330c13 Messed with the boxes 2026-05-11 14:05:11 +01:00
snipe 1706ed597d Updated text 2026-05-11 14:05:03 +01:00
snipe 6e9ba28ef7 Tightened up query string 2026-05-11 13:26:35 +01:00
snipe 554d1a44de More shifting 2026-05-11 13:21:33 +01:00
snipe c0a8f4c1a4 Include withrashed() 2026-05-11 13:19:49 +01:00
snipe 08be9aac6d Added users chart 2026-05-11 13:08:23 +01:00
Brady Wetherington a51b17fb53 Don't 500 on malformed emails input 2026-05-11 13:07:16 +01:00
snipe 66d5618d60 Fixed links in summary box 2026-05-11 12:59:53 +01:00
snipe e16c2384fd Fixed some dark/light mode stuff 2026-05-11 12:55:25 +01:00
snipe b3323f08a0 More b0xen 2026-05-11 12:29:58 +01:00
snipe 7e63c2ef92 CSS is hard :( 2026-05-11 12:25:38 +01:00
snipe 7f65b6d598 Shifting stuff around again 2026-05-11 12:25:32 +01:00
snipe 8fb8f0a4d2 Edited queries in ReportsController 2026-05-11 12:24:05 +01:00
snipe 637dbc8d2a New strings 2026-05-11 12:23:47 +01:00
snipe 978990fdff Added progressbar 2026-05-11 12:23:41 +01:00
snipe 52a058e511 Breaking everything.. whee 2026-05-11 12:16:15 +01:00
snipe 64bea202c5 Switched layout, added chart 2026-05-11 12:01:45 +01:00
snipe 37f60993ca Added charts with date range picker 2026-05-11 11:45:34 +01:00
snipe 32717c67c7 Added reports index to sidenav 2026-05-11 11:45:20 +01:00
snipe 3681e3f025 Removed extranneous div 2026-05-11 11:45:08 +01:00
snipe 1d0f055349 Added new strings 2026-05-11 11:44:58 +01:00
snipe fb3024ca9c Added controller methods for reports 2026-05-11 11:44:52 +01:00
snipe 005c0ea9f6 Pint 2026-05-11 11:44:37 +01:00
snipe 7c3f1f3a84 Added routes 2026-05-11 11:44:29 +01:00
snipe 900e5209d9 Added claude.md :( 2026-05-11 10:21:30 +01:00
snipe 4fbf416d16 Merge remote-tracking branch 'origin/develop' 2026-05-11 09:56:31 +01:00
snipe 7b7d2c87fb Added League\Csv\EscapeFormula for a few more reports 2026-05-11 09:54:55 +01:00
snipe 6debb3a65d Added is_not: as search modifier 2026-05-11 09:46:05 +01:00
snipe 315ba49a1d Larger tag size 2026-05-11 09:43:42 +01:00
snipe ff57855038 Added EthicalCheck
Giving this a test drive
2026-05-08 17:10:40 +01:00
snipe da6e837578 Merge pull request #18991 from uberbrady/better_scim_errors
Fixed #18987 - fix SCIM error on mismapped fields
2026-05-08 14:25:02 +01:00
snipe a2d8f89162 Merge remote-tracking branch 'origin/develop' 2026-05-08 14:21:20 +01:00
snipe e36d65e695 Use carbon instead 2026-05-08 14:21:07 +01:00
snipe 34abf14cbe Merge remote-tracking branch 'origin/develop' 2026-05-08 14:12:29 +01:00
snipe dda7a4f22f Format dates in custom report 2026-05-08 14:12:15 +01:00
Brady Wetherington 283a885196 Get rid of 'setCode' and just use the constructor parameter instead 2026-05-08 13:15:37 +01:00
snipe d44aa3f16e Merge remote-tracking branch 'origin/develop' 2026-05-07 12:42:15 +01:00
snipe 575e825579 Typo 2026-05-07 12:42:04 +01:00
snipe dc8cbf4786 Stricter FMCS enforcement in API 2026-05-07 12:41:46 +01:00
snipe 5f81a48d8b Merge pull request #18986 from grokability/#18905-asset-location-on-checkin
Fixed #18905 - update location of child assets if parent asset is checked in
2026-05-07 12:32:44 +01:00
snipe c22e4c00a5 Fixed #18905 - update location of child assets if parent asset is checked in 2026-05-07 12:20:25 +01:00
snipe afb37981bf Merge remote-tracking branch 'origin/develop' 2026-05-07 12:07:46 +01:00
snipe 9b5ead39d3 Merge pull request #18985 from grokability/#18959-slack-notification-location
Fixed #18959 - refresh data on checkout notification
2026-05-07 12:06:19 +01:00
snipe 158e66f9c6 Fixed #18959 - refresh data on checkout notification 2026-05-07 11:59:01 +01:00
snipe bd8e944e2f Merge pull request #18967 from marcusmoore/fixes/test-namespace
Fixed namespace for test class
2026-05-07 11:13:31 +01:00
snipe 2b6518427a Merge remote-tracking branch 'origin/develop' 2026-05-07 11:11:16 +01:00
snipe 06d95b679b Merge pull request #18983 from grokability/add-option-to-export-only-assigned-or-unassigned
Allow custom report to filter on assigned/unassigned
2026-05-07 11:11:01 +01:00
snipe ff75b9eed8 Merge pull request #18982 from marcusmoore/consolidate-test-macros
Improved test macros for streamed content
2026-05-07 11:02:28 +01:00
snipe 17a88fcb80 Allow custom report to filter on assigned/unassigned 2026-05-07 11:00:43 +01:00
snipe 185e0073b3 Merge remote-tracking branch 'origin/develop' 2026-05-07 10:40:10 +01:00
snipe eca34de593 Added null-safe operator for components and consumables 2026-05-07 10:39:57 +01:00
snipe d0794ba71c Merge remote-tracking branch 'origin/develop' 2026-05-07 10:37:15 +01:00
snipe 40e89756bf Extend new operators to custom fields 2026-05-07 10:36:59 +01:00
Marcus Moore 55e46b2d15 Improve macro 2026-05-06 12:08:18 -07:00
Marcus Moore 02383aad7b Fix assertSeeTextInStreamedResponse and assertDontSeeTextInStreamedResponse macros
These were previously only checking the first column and not all of the data.
2026-05-06 11:46:28 -07:00
Marcus Moore e75f54cc1c Move helper macros to CustomTestMacros 2026-05-06 11:40:37 -07:00
snipe 1b42e2e138 Merge remote-tracking branch 'origin/develop' 2026-05-06 17:50:59 +01:00
snipe 3668c24d02 Pint again 2026-05-06 17:50:45 +01:00
snipe a84533b4f4 Quick tweak to advanced search aliases 2026-05-06 17:50:33 +01:00
snipe b4efabe82e Merge remote-tracking branch 'origin/develop' 2026-05-06 16:38:06 +01:00
snipe cbe750cc9e Merge pull request #18980 from uberbrady/reduce_scim_error_level
Throw 4xx SCIMExceptions when SCIM clients send bad data
2026-05-06 16:09:53 +01:00
snipe a77dedf3d7 Merge pull request #18979 from uberbrady/improve_saml_nonces
Add new unique constraint and improved nonce-checking logic for SAML
2026-05-06 15:26:39 +01:00
Brady Wetherington b6ce823cc2 Make sure to throw 400-series SCIMExceptions when SCIM clients send bad data 2026-05-06 15:24:58 +01:00
snipe f7e8ce2ade Merge pull request #18969 from grokability/advanced-search-improvements
🎥 Advanced search improvements
2026-05-06 13:02:07 +01:00
snipe 62e5b71dc1 Added loads of comments - this is gnarly stuff 2026-05-06 12:40:40 +01:00
snipe 3d04324595 Added searchableRelationAliases to user model 2026-05-06 12:39:31 +01:00
snipe 468cf73b97 Updated help text 2026-05-06 12:38:15 +01:00
snipe 5b90f9fb87 Switched to templates for readability (still gross, but whatever) 2026-05-06 12:37:57 +01:00
snipe 9131dbf09b Added more filter options 2026-05-06 12:03:55 +01:00
snipe 9b37e95b58 Merge remote-tracking branch 'origin/develop' 2026-05-05 22:00:13 +01:00
snipe a425234365 Fixed typo, added context (“worm”? Really?) 2026-05-05 22:00:02 +01:00
snipe a92d8eeaab Merge remote-tracking branch 'origin/develop' 2026-05-05 20:37:03 +01:00
snipe cd4e268c72 Added/updated tests 2026-05-05 20:36:39 +01:00
snipe b94945a461 Fixed RB-4121 2026-05-05 20:36:31 +01:00
Brady Wetherington 5b0a779c07 Add new unique constraint and improved nonce-checking logic 2026-05-05 15:23:07 +01:00
snipe e8dbb12ccc Merge remote-tracking branch 'origin/develop' 2026-05-05 13:22:59 +01:00
snipe d099bf2983 Merge pull request #18970 from uberbrady/fix_case_sensitive_classname
Change capitalization on SCIMUser; Linux filenames are case-sensitive
2026-05-05 13:22:28 +01:00
Brady Wetherington f7add0e4dd Change capitalization on SCIMUser; Linux filenames are case-sensitive 2026-05-05 13:19:59 +01:00
snipe 1e1cc897ad Added search help 2026-05-05 12:53:30 +01:00
snipe 04e2c59aa9 Typo 2026-05-05 12:40:56 +01:00
snipe 03bd3517be Added ablity to use “not:” or “!” to exclude results 2026-05-05 12:40:42 +01:00
snipe eeba5bc8fd Cleanup 2026-05-05 12:30:25 +01:00
snipe 1f54180c9c Removed highlighting in advanced search 2026-05-05 12:20:36 +01:00
snipe 8497a27c81 Added tags 2026-05-05 11:45:43 +01:00
snipe 80afa470ee Fixed issue where the button classes would get overwritten when closed and the modal re-opened 2026-05-05 11:39:45 +01:00
snipe 10c750e1a2 Added localstorage to handle remembering and/or 2026-05-05 11:33:56 +01:00
snipe 3aa175b36d Added and/or operator 2026-05-05 11:32:08 +01:00
snipe 8a2cd19ea6 Merge remote-tracking branch 'origin/develop' 2026-05-05 10:58:55 +01:00
snipe e76036965b Same for assets 2026-05-05 10:58:43 +01:00
snipe 2bb86a2ec1 Fixed RB-20854 - only allow scalars for users/hardware query strings 2026-05-05 10:52:51 +01:00
Marcus Moore a89c8c6e5b Fix namespace 2026-05-04 13:47:58 -07:00
Marcus Moore 1bdf205ca6 Run pint on test 2026-05-04 13:47:47 -07:00
snipe afdf86ad0d Merge remote-tracking branch 'origin/develop' 2026-05-04 21:47:15 +01:00
snipe ccf801137a Fixed typo 2026-05-04 21:46:47 +01:00
snipe ef746a173e Fixed RB-4120 - Column 'location_id' in where clause is ambiguous 2026-05-04 21:46:40 +01:00
snipe a5dae3f222 Merge remote-tracking branch 'origin/develop' 2026-05-04 20:55:35 +01:00
snipe e3552f4e36 Merge pull request #18966 from uberbrady/scim_php_82_fixes
Switch to PHPv8.2-compatible way of invoking a constructor and a method
2026-05-04 20:41:34 +01:00
snipe 75d9357488 Removed files notes field - confusing and kinda redundant 2026-05-04 20:34:12 +01:00
Brady Wetherington 26c028cf37 Switch to PHPv8.2-compatible way of invoking a constructor and a method 2026-05-04 20:34:01 +01:00
snipe 10c483967f Merge pull request #18965 from grokability/#18952-upload-files-directly-from-create/edit-screen-for-maintenances
🎥 Fixed #18952 - allow non-image files to be uploaded on create/edit maintenances
2026-05-04 20:33:16 +01:00
snipe 07b33e8189 Fixed #18952 - allow non-image files to be uploaded on create/edit maintenances 2026-05-04 20:15:56 +01:00
snipe 97765c08b1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:58:48 +01:00
snipe fc3ea78005 Fixed #18780 - limit height for tall images in info-panel 2026-05-04 19:58:37 +01:00
snipe 6ad92556a1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:36 +01:00
snipe bd4150af5a Merge pull request #18964 from grokability/fixes-for-maintenance-permissions
Fixed #18951 - maintenance permissions
2026-05-04 19:47:15 +01:00
snipe e2465ca2a7 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:03 +01:00
snipe 1c6c93da35 Fixed typo 2026-05-04 19:30:21 +01:00
snipe 0daec32ddd Added dedicated maintenance permission (related right now just to assets) 2026-05-04 19:30:11 +01:00
snipe e466ed9e06 Merge pull request #18184 from uberbrady/use_new_laravel_scim_server
Use new laravel SCIM server
2026-05-04 18:49:44 +01:00
Brady Wetherington 4445b0317f Re-generated lockfile hash with minimal changes 2026-05-04 18:43:20 +01:00
Brady Wetherington beaea6c3bf Merge branch 'develop' into use_new_laravel_scim_server 2026-05-04 18:39:48 +01:00
snipe f5644928a8 Prod assets 2026-05-04 13:24:50 +01:00
snipe a279c44aa5 Pint 2026-05-04 13:23:49 +01:00
snipe f1f96e574c Bumped hash 2026-05-04 13:23:14 +01:00
snipe 1879001ef3 Merge remote-tracking branch 'origin/master' into develop 2026-05-04 13:21:34 +01:00
snipe 5014b1c459 Fixed #18955 - added manufacturer to view-assigned view 2026-05-04 13:21:12 +01:00
snipe 903459cf7e Merge remote-tracking branch 'origin/master' into develop 2026-05-04 13:12:54 +01:00
snipe 7c04661cfa Merge pull request #18963 from grokability/_add-custom-fields-to-eula-pdf
Add custom fields to eula pdf
2026-05-04 13:07:56 +01:00
snipe 76d3194c96 Shifted layout 2026-05-04 12:57:26 +01:00
snipe b63aee2851 Added custom fields to EULA PDFs 2026-05-04 12:51:43 +01:00
snipe f57d2608c5 Fixed #18956 - hide well if no matching graphs are present 2026-05-04 12:20:25 +01:00
snipe 34331525b1 Merge remote-tracking branch 'origin/master' into develop 2026-05-04 12:17:28 +01:00
snipe 8d1f4427ae Merge pull request #18910 from grokability/audit-visibility-fix
Fixed #18896 - Audit visibility fix
2026-05-04 12:14:05 +01:00
snipe 7f89f8284f Merge pull request #18961 from grokability/add-visibility-icons
🖼️ Added visibility icons in custom fields forms
2026-05-04 12:12:56 +01:00
snipe 3b2ac2bc3c Fixed classname 2026-05-04 12:06:42 +01:00
snipe 73e88be8f3 Small cleanup 2026-05-04 12:05:48 +01:00
snipe f5d092f497 Added aria label for accessibility 2026-05-04 11:56:53 +01:00
snipe 8edbad92cb Added visual icons to show where the custom fields will be visible 2026-05-04 11:55:55 +01:00
snipe b0e13a1352 Merge pull request #18946 from marcusmoore/8.5-actions
Added PHP 8.5 to Action tests
2026-04-30 10:03:48 +01:00
snipe 5c75648cd7 Merge remote-tracking branch 'origin/master' into develop 2026-04-28 19:38:44 +01:00
snipe 1872c6eed9 Merge pull request #18950 from grokability/show-hide-password
🎥 Added password toggle JS/HTML
2026-04-28 10:42:21 +01:00
snipe 53199b9737 Added password toggle JS/HTML 2026-04-28 10:35:36 +01:00
snipe 73861c6a04 Merge pull request #18948 from marcusmoore/fixes/index-history-test
Fixed test name
2026-04-27 22:58:34 +01:00
Marcus Moore e2969dd3e2 Fix filename 2026-04-27 13:59:40 -07:00
snipe 0b1b99697e Merge pull request #18947 from spencerrlongg/mobile-client-endpoint
Mobile OAuth Client Endpoint
2026-04-27 20:05:47 +01:00
snipe 07202a8061 Merge pull request #18937 from uberbrady/fix_saml_intended_url
Fix redirecting users to their intended URL's when logging in via SAML
2026-04-27 19:54:09 +01:00
spencerrlongg 189454096b route for mobile client authentication 2026-04-27 13:52:55 -05:00
Marcus Moore 55ee5df852 Merge branch 'develop' into 8.5-actions 2026-04-27 11:52:29 -07:00
snipe f6466b9154 Merge pull request #18945 from marcusmoore/deps/phpspec/prophecy
Bumped phpspec/prophecy to allow installing on PHP 8.5
2026-04-27 19:51:24 +01:00
Marcus Moore 8e5a64dca9 Add php 8.5 to testing workflows 2026-04-27 11:48:26 -07:00
Marcus Moore b894147514 Bump phpspec/prophecy to v1.26.1 2026-04-27 11:27:41 -07:00
snipe d55c2c269f Merge remote-tracking branch 'origin/master' into develop 2026-04-27 19:05:50 +01:00
Brady Wetherington c7afcf0bef Fix returning to intended URL on 2-factor success (or enrollment success) 2026-04-27 14:59:32 +01:00
Brady Wetherington c79f5b8b74 Merge branch 'develop' into use_new_laravel_scim_server 2026-04-27 14:15:27 +01:00
snipe c5296fd76d Fixed selected attribute on multiselect (normal, not select2) for groups 2026-04-27 13:49:11 +01:00
snipe 3cb3284b26 Merge pull request #18942 from grokability/#18939-blank-tag-after-submit
Fixed #18939 - blank audit field in scanner audit screen
2026-04-27 13:22:12 +01:00
snipe d5d0d00ecc Fixed #18939 - blank audit field in scanner audit screen 2026-04-27 13:20:08 +01:00
Brady Wetherington dc6b45cbcb Fix redirecting users to their intended URL's when logging in via SAML 2026-04-23 15:29:28 +01:00
snipe 5db9d67e65 Merge pull request #18936 from grokability/info-panel-button-blade
Refactor show/hide info button into blade component
2026-04-23 14:26:19 +01:00
snipe f64dfa7f92 Refactor show/hide info button into blade component 2026-04-23 14:18:19 +01:00
snipe 06584d17a6 Merge pull request #18874 from marcusmoore/fixes/fd-54740-user-avatar-via-api-master
[FD-54740] Fixed managing user avatar via API
2026-04-23 13:02:36 +01:00
snipe 73bbe5062d Merge remote-tracking branch 'origin/master' into develop 2026-04-22 17:37:29 +01:00
snipe 75cb1041ec Merge pull request #18932 from grokability/small-tweak-to-info-element-status
🎥 Small tweak to info element status
2026-04-22 17:37:15 +01:00
snipe b61ed66d9d Updated logic 2026-04-22 17:25:48 +01:00
snipe 48ebd7faf5 Refine checkin checkout button display 2026-04-22 17:25:26 +01:00
snipe d6de3baa6e Hide button if orphaned 2026-04-22 17:21:33 +01:00
snipe 1be44a4c05 Pint 2026-04-22 17:18:07 +01:00
snipe f17f34f730 Added routes and logging 2026-04-22 17:17:58 +01:00
snipe da5bb6126a Add a warning well if the asset’s checkout is bonked 2026-04-22 17:09:58 +01:00
snipe a5d04d2e65 Added tests 2026-04-22 17:09:34 +01:00
snipe 22d07214fe Sort of fixed #18918 - prevent showing more permissions if user is admin or superadmin 2026-04-22 15:50:27 +01:00
snipe 8d4523d250 Merge pull request #18930 from grokability/#18920-sort-by-eula-in-categories
Added #18920 - sort by EULA in categories
2026-04-22 15:30:55 +01:00
snipe 37a3d694d4 Pint 2026-04-22 15:24:52 +01:00
snipe d21ccdfcbf Added #18920 - sorting by EULA in category view 2026-04-22 15:24:45 +01:00
snipe 11eaf7ce7b Merge remote-tracking branch 'origin/master' into develop 2026-04-22 15:01:22 +01:00
snipe 4eba97d388 Added Armenian as a possible language 2026-04-22 15:01:11 +01:00
snipe 590e97a99f Merge remote-tracking branch 'origin/master' into develop 2026-04-22 14:58:25 +01:00
snipe 613137551a Pint 2026-04-22 14:58:03 +01:00
snipe 23f941c810 Updated language strings 2026-04-22 14:57:16 +01:00
snipe 4c09f3a229 Merge remote-tracking branch 'origin/master' into develop 2026-04-22 14:38:39 +01:00
snipe e7312801ac Fixed division by zero in new label engine 2026-04-22 14:24:11 +01:00
snipe cd69a7ea53 Merge pull request #18927 from grokability/enforce-basenames-on-filenames
Enforce basenames on filenames
2026-04-22 13:42:09 +01:00
snipe 13ffdda12e Piiiint 2026-04-22 13:35:24 +01:00
snipe 372e74aad3 Adds basename to places where we pass a filename 2026-04-22 13:35:16 +01:00
snipe 2a32f7d372 Merge pull request #18926 from grokability/#18172-correctly-decrypt-encrypted-custom-fields-in-custom-asset-report
Fixed #18172 - Encrypted custom fields not correctly decrypted in custom asset report
2026-04-22 12:56:11 +01:00
snipe a2d34cca76 Pint 2026-04-22 12:49:55 +01:00
snipe c513ed5fc3 Fixed #18172 - correctly dencrypt custom fields in custom asset report 2026-04-22 12:49:48 +01:00
snipe 34cd5dcf7c Add @Husky-Devel as a contributor 2026-04-22 12:24:19 +01:00
snipe 260ca085bb Merge pull request #18908 from Husky-Devel/patch-1
Update snipeit.sh script to add support for CentOS/Alma/Redhat 10.x
2026-04-22 12:22:59 +01:00
snipe 7b00074b9e Merge pull request #18915 from Godmartinz/fix-api-label-with-old-engine
Adds translation and better error messaging for API labels
2026-04-22 12:22:33 +01:00
snipe 16e981d99d Merge pull request #18916 from marcusmoore/fixes/fd-54943-acceptance-email
Fixed display of email setting on category show page
2026-04-22 12:21:47 +01:00
snipe 16eb899ba7 Merge pull request #18924 from grokability/#18367-add-option-to-audit-by-serial-in-quickscan
Fixed #18367 - Added option to audit by serial in quickscan audit
2026-04-22 12:21:13 +01:00
snipe 3367f8e5c7 Edited audit method to allow searching by serial 2026-04-22 12:04:27 +01:00
snipe ad635ab95c Updated text 2026-04-22 12:04:11 +01:00
snipe b94e7fd8a0 Added select2 2026-04-22 12:04:04 +01:00
snipe 683fbd7953 Moved nav option 2026-04-22 12:00:41 +01:00
snipe 246ec9e20b Added tests 2026-04-22 11:57:58 +01:00
snipe 81d669d62a Pint 2026-04-22 09:05:01 +01:00
snipe 9ff951d379 Added redundent migration for delete_at 2026-04-22 09:04:53 +01:00
Marcus Moore e327303b3c Fix display of alert_on_response 2026-04-20 16:18:23 -07:00
snipe 21d030db26 Merge pull request #18914 from Godmartinz/rb20958
Fix RB-20958 company display name in custom reports
2026-04-20 20:31:57 +01:00
Godfrey M 444b58504c rename translation 2026-04-20 11:27:56 -07:00
Godfrey M c1e2f4ad75 update api label exception messages to translations 2026-04-20 11:10:41 -07:00
Godfrey M ec6778e770 adds ternary to user company display in custom reports 2026-04-20 10:09:44 -07:00
snipe 1c5d81cb04 Parse through carbon to make suyre the dates match properly 2026-04-20 15:54:45 +01:00
snipe 10e6c93a95 Merge remote-tracking branch 'origin/master' into develop 2026-04-20 15:40:55 +01:00
snipe d37f43daba Merge pull request #18912 from grokability/small-info-panel-tweaks
Small info panel tweaks
2026-04-20 15:40:39 +01:00
snipe dbabd1bab3 Removed stupid space from phpstorm 2026-04-20 15:34:25 +01:00
snipe 6b5398139a Pull date out of the progress bar
This was squishing weird on smaller screens
2026-04-20 15:32:24 +01:00
snipe 0ec45a4fd0 Add text to dates, added class if almost depreciated 2026-04-20 15:31:16 +01:00
snipe b99fd237f3 Use refactored methods 2026-04-20 15:30:55 +01:00
snipe 5d7123eb05 Added tests 2026-04-20 15:30:25 +01:00
snipe 7eb6ebb60d Refactor the percent logic out of the blade 2026-04-20 15:30:16 +01:00
snipe 0060207816 Merge remote-tracking branch 'origin/master' into develop 2026-04-20 14:19:38 +01:00
snipe 5bc273686e Use support_url in manufacturer blade component 2026-04-20 14:18:58 +01:00
snipe 2f6420e05f Pint 2026-04-20 13:57:50 +01:00
snipe c01699b6e4 Skip checking for company_id on models table 2026-04-20 13:57:45 +01:00
snipe 6c6199add8 Narrowed test 2026-04-20 13:40:49 +01:00
snipe 42cd5e0017 Pint :( 2026-04-20 13:30:22 +01:00
snipe baee6a37ea Backfill audit company ID (if one is present) 2026-04-20 13:30:15 +01:00
Peter Gallwas 90b3685808 Add support for CentOS/Alma/Redhat 10.x
Based off the 9.x code add 10.x support, tested on Rocky 10.1
2026-04-20 18:47:03 +12:00
snipe 37a37318aa Merge pull request #18901 from grokability/display-effective-permissions
Display effective permissions
2026-04-17 12:50:58 +01:00
snipe 74e831c4f0 Oh, Pint 2026-04-17 12:36:56 +01:00
snipe be36390b0f Display effective permissions on user view 2026-04-17 12:36:43 +01:00
snipe e9a628066f Merge pull request #18889 from fvollmer/ldap-deletion
Added #14662: Allow (soft) deletion via ldap sync
2026-04-17 12:14:29 +01:00
ArturoSirvent 8f46b5254e Fix backup disk driver configuration for S3 support
- Fix the backup disk in config/filesystems.php to use a dedicated BACKUP_FILESYSTEM_DRIVER env var instead of PRIVATE_FILESYSTEM_DISK
- Add AWS credential fields to the backup disk config so S3 backups work
- Use BACKUP_FILESYSTEM_ROOT with safe default (storage_path('app')) for local driver
- Document BACKUP_FILESYSTEM_DRIVER and BACKUP_FILESYSTEM_ROOT in .env.example

Fixes #14057
2026-04-17 12:10:03 +01:00
snipe cf44119bc6 Merge pull request #18710 from ArturoSirvent/fix/backup-disk-s3-driver
Fix backup disk driver configuration for S3 support
2026-04-17 12:09:34 +01:00
snipe a15e9d737c Merge pull request #18882 from Godmartinz/purge-old-eula-option-for-deleted-users
adds #18868 options to Eula Purge Command
2026-04-17 12:08:20 +01:00
snipe 08f6f5cf71 Merge remote-tracking branch 'origin/master' into develop 2026-04-17 12:04:50 +01:00
snipe faa2adbde2 Pint 2026-04-17 12:04:31 +01:00
snipe 7fae60d5c3 Log requestable check on checkin/checkout 2026-04-17 12:04:21 +01:00
snipe 4f9ce07304 Merge remote-tracking branch 'origin/master' into develop 2026-04-17 11:58:04 +01:00
snipe 3cad34821e Merge pull request #18900 from grokability/#7418-toggle-requestable
Fixed #7418 - Ability to toggle requestable on checkin/checkout
2026-04-17 11:57:46 +01:00
snipe 1e4353f0db Added controller logic and form request constraints 2026-04-17 11:40:37 +01:00
snipe 7520a1b2a3 Allow the user to leave the requestability unchanged in bulk checkout 2026-04-17 11:38:45 +01:00
snipe cdd91e498a Updated test 2026-04-17 11:37:58 +01:00
snipe 2daf0458a7 Added checkboxes and JS to checkin/checkout blades 2026-04-17 11:35:10 +01:00
snipe 6299fc09bf Added tests 2026-04-17 11:33:12 +01:00
snipe 2327cc6866 Merge pull request #18895 from grokability/resend-acceptance-on-user-page
Resend acceptance on user page
2026-04-16 20:01:50 +01:00
snipe b235df0bbf Fixed div 2026-04-16 19:57:25 +01:00
snipe 6d56ab9b63 Re-added import 2026-04-16 19:51:50 +01:00
snipe ce5de8fe06 Allow admin to resend unaccepted assets 2026-04-16 15:50:11 +01:00
snipe ce3f80246e Pint 2026-04-16 15:49:32 +01:00
snipe 046ef82c65 Added gates for 2FA reset API endpoint 2026-04-16 15:49:25 +01:00
snipe 0f347e8453 Use translation for unauthorized 2026-04-16 15:28:36 +01:00
snipe 1cb0ca84ab Add wrapping for address 2026-04-16 14:52:03 +01:00
snipe 7625646c11 Remove wrapping from wells 2026-04-16 14:51:42 +01:00
snipe 743c598b83 Merge remote-tracking branch 'origin/master' into develop
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-04-16 14:13:02 +01:00
snipe 324530fb8c Updated language 2026-04-16 14:12:07 +01:00
snipe 68acf7b90a Merge pull request #18884 from grokability/#8414-sign-in-place
Added #8414 - acceptance sign in place
2026-04-16 14:11:11 +01:00
snipe 9c610f51af Updated language, nicer form 2026-04-16 13:37:31 +01:00
snipe 40ec0627c4 Check for global acceptance requirement as well 2026-04-16 13:14:21 +01:00
snipe 645e66b30c Updated language 2026-04-16 13:07:32 +01:00
snipe 2311d56836 Only show the sign-in-place box if the category requires it 2026-04-16 13:06:18 +01:00
snipe 1f3481c54b Use breadcrumb action in route 2026-04-16 12:51:57 +01:00
snipe 07fa51aa4c Lots of tests 2026-04-16 12:51:38 +01:00
snipe 0866469cc0 Added checkout date and admin to acceptance form 2026-04-16 12:51:22 +01:00
snipe 3d9bb29b1b Updated string 2026-04-16 12:50:59 +01:00
snipe 5a67bcaf17 Pint doing pint things 2026-04-16 12:50:52 +01:00
snipe 01b18513f1 Safe redirect if request is weird 2026-04-16 12:49:02 +01:00
snipe d92ec582fa Check for “stale” acceptance 2026-04-16 12:47:30 +01:00
snipe 205eb7fd37 Moved breadcrumbs into action to get the logic out of the route 2026-04-16 12:35:31 +01:00
snipe 0798e62417 Normalize the box header - it looked kinda weird before 2026-04-16 11:31:12 +01:00
snipe 83adcc61bc More cleanup 2026-04-16 11:22:39 +01:00
snipe 788e07947f Nicer styling 2026-04-16 11:14:35 +01:00
snipe f7717571ea Merge remote-tracking branch 'origin/master' into develop 2026-04-16 10:01:04 +01:00
snipe 83fec75bc8 Avoid crashing if assignedto isn’t valid 2026-04-16 10:00:45 +01:00
snipe 53c240f13f Pint 2026-04-15 21:29:32 +01:00
snipe f142eb7a44 Moved back in time migration 2026-04-15 21:29:26 +01:00
Felix Vollmer fe84d35ce4 Added #14662: Allow (soft) deletion via ldap sync 2026-04-15 17:14:53 +02:00
Godfrey M 5c5414c960 remove comments, reorder command options 2026-04-14 17:57:06 -07:00
Godfrey M 2eeb1f588a rename tests 2026-04-14 16:13:18 -07:00
Godfrey M 9f69eacf71 tweak to acceptance factory, adds purge eula command tests 2026-04-14 16:11:32 -07:00
snipe 495382c42f Merge pull request #18885 from grokability/fix-soft-deleted-companies-in-migrations
Fix soft deleted companies in migrations
2026-04-14 23:19:33 +01:00
snipe 029634707b Oh, Pint 2026-04-14 22:50:32 +01:00
snipe fd5736fac4 Belt and suspenders 2026-04-14 22:50:21 +01:00
snipe f3ed2d9dd8 Derp. Run if the column DOES exist 2026-04-14 22:38:44 +01:00
snipe 676cd66e4b Make the temp datetime nullable 2026-04-14 22:31:00 +01:00
snipe 17c73c4017 Pint :( 2026-04-14 22:30:26 +01:00
snipe 5983a4530f Fix back in time migrations for very old restores or upgrades 2026-04-14 22:30:05 +01:00
snipe 1cd8395b23 Piiiint 2026-04-14 21:44:08 +01:00
snipe ea4374a855 Oops. Forgot to commit accessories :D 2026-04-14 21:44:00 +01:00
snipe 061b913413 Better naming for session variable 2026-04-14 20:54:42 +01:00
Godfrey M b2fda13ac3 adds option for only deleted users and company id for eula purging 2026-04-14 11:58:54 -07:00
snipe e79af0163a Oh, Pint 2026-04-14 19:49:18 +01:00
snipe 91e41049bd Skip the initial checkout email to the recipient if sign_in_place was checked 2026-04-14 19:49:05 +01:00
snipe 18f67bcce5 Store the sign in place in the session so it’s remembered 2026-04-14 12:20:51 +01:00
snipe 264347e323 Added redirects 2026-04-14 12:16:46 +01:00
snipe 18d9f7dbf1 Updated JS assets 2026-04-14 12:13:59 +01:00
snipe 702af91c84 Added tests 2026-04-14 12:13:02 +01:00
snipe e9db3b3861 Added sign-in-place language keys 2026-04-14 12:12:54 +01:00
snipe 896922fde5 Added acceptance checkbox to checkout blades 2026-04-14 12:09:42 +01:00
snipe 7c2bb69bc9 Nicer width for field 2026-04-14 12:02:43 +01:00
snipe d2921346a2 Fixed datepicker width 2026-04-14 11:07:59 +01:00
snipe cc06b2f0eb Merge pull request #18878 from grokability/fixes-n+1-on-history
Fixes n+1 on history
2026-04-14 11:06:44 +01:00
snipe d98c7bddba Added test to prevent n+1 regression 2026-04-14 10:25:55 +01:00
snipe fe308ef2e4 Use query clone to prevent n+1 2026-04-14 10:25:37 +01:00
snipe 9b43835e2d Added forHistory scope in ActionLog, use it in Loggable 2026-04-14 10:23:36 +01:00
snipe 88d34a5b92 Merge remote-tracking branch 'origin/master' into develop 2026-04-14 09:27:28 +01:00
snipe 019f0af282 Fixed typo 2026-04-14 09:13:45 +01:00
Marcus Moore c6619a621c Move comment 2026-04-13 16:42:47 -07:00
Marcus Moore 565e3de183 Allow updating user avatar via API 2026-04-13 16:41:21 -07:00
snipe b91dd15f96 Merge remote-tracking branch 'origin/master' into develop 2026-04-13 16:19:51 +01:00
snipe 1a852cdacf Merge pull request #18872 from grokability/location-breadcrumbs
Fixes #9037 - Added breadcrumbs to top breadcrumb trail
2026-04-13 16:19:34 +01:00
snipe 4ea527d980 Added breadcrumb to info-panel 2026-04-13 16:16:33 +01:00
snipe 392af4f127 Fixes #9037 - Added breadcrumbs to top breadcrumb trail 2026-04-13 16:09:10 +01:00
snipe 6e8e72f281 Merge remote-tracking branch 'origin/master' into develop 2026-04-13 15:52:11 +01:00
snipe 1c198500c6 Merge pull request #18871 from grokability/#18869-throw-error-if-no-MAIL_REPLYTO_ADDR-is-set
Fixed #18869 - skip mail test if no `MAIL_REPLYTO_ADDR` is given
2026-04-13 15:20:45 +01:00
snipe 8620f25c0e Fixed #18869 - skip mail test if no MAIL_REPLYTO_ADDR is given 2026-04-13 15:17:23 +01:00
snipe 1311ce48d3 Merge remote-tracking branch 'origin/master' into develop 2026-04-13 15:01:01 +01:00
snipe 8a59809937 Merge pull request #18870 from grokability/#18736-add-cumulative-cost-of-an-asset-with-maintenances
#18736 add cumulative cost of an asset with maintenances
2026-04-13 15:00:38 +01:00
snipe bac8299ea6 Added accessor for component qty 2026-04-13 14:51:48 +01:00
snipe 500dcdd582 Re-jigger the order 2026-04-13 14:51:24 +01:00
snipe cbae494c54 Fixed totals 2026-04-13 14:08:23 +01:00
snipe 09bec66406 Meh 2026-04-13 13:55:05 +01:00
snipe 2b2291dc7e Pint 2026-04-13 13:48:21 +01:00
snipe 1357b45e24 Added content to blade, refactoring some relationships 2026-04-13 13:48:14 +01:00
snipe 4a0dbba3ec Dev assets 2026-04-13 11:59:47 +01:00
snipe fcd0360135 Merge remote-tracking branch 'origin/master' into develop 2026-04-13 11:57:54 +01:00
snipe 6935cf1dde Merge pull request #18867 from grokability/fixed-#18856-duplicate-icons
Fixed #18856 - clicking and canceling would result in multiple icons in modal
2026-04-13 11:57:36 +01:00
snipe cf384373df Fixed #18856 - clicking and canceling would result in multiple icons in modal 2026-04-13 11:50:41 +01:00
snipe 48c4f34af3 Fixed #18863 - backfill status vs status_type for older integrations 2026-04-13 11:08:14 +01:00
snipe 7b800152ee Merge pull request #18866 from grokability/adds-validation-check-console-command
Adds validation check console command - helps with #18851
2026-04-13 10:45:49 +01:00
snipe f289691e22 Merge pull request #18865 from Joly0/patch-1
Add artisan command to clear compiled views (for docker startup.sh)
2026-04-13 10:45:25 +01:00
snipe 50421494c5 Pint 2026-04-13 10:33:55 +01:00
snipe 33b8861ae3 Adds conosole command for listing invalid assets 2026-04-13 10:33:49 +01:00
Joly0 31f90d20f8 Add artisan command to clear compiled views
After the recent update to 8.4.1 this command was mandatory, but wasnt applied to docker environment, therefore (atleast for my company) views were broken (empty licenses for example). Had to manually exec into the container and execute this command.

Adding it to the startup script should bring no real downsides, but should fix this for all others and all future version of snipe-it that have the same requirement for clearing compiled views
2026-04-13 09:49:51 +02:00
snipe a94ba474f3 Updated version in confog for develop 2026-04-11 11:05:10 +01:00
snipe a81ab0ea0f Merge remote-tracking branch 'origin/master' into develop 2026-04-11 11:04:29 +01:00
snipe 87e65893d3 Updated tests with new text 2026-04-11 10:48:37 +01:00
snipe 405540aea2 Clarified text 2026-04-11 10:38:42 +01:00
snipe ccfebee5f1 Not sure why the timestamps wouldn’t handle this for us, but… 2026-04-11 10:34:03 +01:00
snipe 1d9469a3df Fix action_date on action_logs on bulk checkin and delete 2026-04-11 10:32:04 +01:00
snipe 5417bf3445 Merge pull request #18860 from uberbrady/fix_migrations
Move migration to fire after deleted_at column added to companies
2026-04-09 21:22:58 +01:00
snipe 51ea1327cf Merge pull request #18861 from grokability/log-authed-user-header
Log authed user ID header
2026-04-09 20:16:12 +01:00
Brady Wetherington 8113ddb2d5 Re-Add the old migration as an 'empty migration' just for safety 2026-04-09 20:10:02 +01:00
snipe a88ad35b68 Added token name and ID 2026-04-09 19:35:09 +01:00
snipe 6e60f59265 Changed the name because reasons 2026-04-09 19:24:11 +01:00
snipe a866bfafcd Oh ffs pint 2026-04-09 19:23:29 +01:00
snipe 97d1677568 Check for bearer token in header 2026-04-09 19:23:21 +01:00
snipe f4562db0c0 Pint 2026-04-09 19:19:56 +01:00
snipe a616da3e5c Moved to an API-only header 2026-04-09 19:19:50 +01:00
snipe a895566b02 Pint fixes 2026-04-09 19:09:32 +01:00
snipe 5d75765aae Optionally log the user’s ID in the header 2026-04-09 19:09:21 +01:00
snipe 3bc34fcd5e Added nice icons for revoked/not revoked status 2026-04-09 18:46:18 +01:00
snipe 99c3ac56e9 Fixed typo 2026-04-09 18:32:50 +01:00
Brady Wetherington 95c7d5eeff Move migration to fire _after_ the deleted_at column is added to companies 2026-04-09 18:19:28 +01:00
snipe 371f0b82f6 This is superadmin, do not scope to just the authed user 2026-04-09 18:06:27 +01:00
snipe 114b5d3db0 Updated text 2026-04-09 18:01:44 +01:00
snipe 9ab0f60b41 Merge pull request #18858 from grokability/_api-token-rework
Api token rework
2026-04-09 17:42:09 +01:00
snipe 33b8226ebe A little more cleanup 2026-04-09 17:36:32 +01:00
snipe e062062cb3 Added livewire component for persoinal access tokens for admin 2026-04-09 17:24:51 +01:00
snipe 5f713862fb Updated tests 2026-04-09 17:24:20 +01:00
snipe fe013b5ea0 A bit more polish 2026-04-09 17:20:16 +01:00
snipe 57df2dc2cf Removed extra column 2026-04-09 16:21:39 +01:00
snipe c86fa4c521 Moved tabs 2026-04-09 16:00:49 +01:00
snipe c90acf53d5 One more quick fix for consumables qty percent 2026-04-09 15:14:10 +01:00
snipe fece4d2fdc Handle 0 qty for consumables 2026-04-09 15:10:58 +01:00
snipe f49837e5fe Fix division by zero? 2026-04-09 15:08:46 +01:00
snipe e6ead7c6fa Added tests 2026-04-09 14:52:30 +01:00
snipe 3e84af83d8 Added translations to OAuthClients livewire 2026-04-09 14:52:22 +01:00
snipe aff375c799 Added OAuth table button - I don’t know if this really works properly? 2026-04-09 14:52:01 +01:00
snipe 6ada1a646f Tabbed UI 2026-04-09 14:51:33 +01:00
snipe 42bfd24a8f Expanded OAuth language 2026-04-09 14:49:33 +01:00
snipe c7c6b41dab Added revoke to user model 2026-04-09 14:49:12 +01:00
snipe 449e6b5f5c Added revoke controllers 2026-04-09 14:48:50 +01:00
snipe bbe0c7409f Added TokenRevoked action 2026-04-09 14:48:22 +01:00
snipe 41bb9c378b Added keywords for translation 2026-04-09 14:48:03 +01:00
snipe 9a82b890d4 Translation 2026-04-09 14:47:52 +01:00
snipe 2d2180c9e8 Added revoke/unrevoke routes 2026-04-09 14:26:11 +01:00
snipe 798a590c2a Pint gonna pint 2026-04-09 14:03:37 +01:00
snipe 8a08878062 Added language strings 2026-04-09 14:03:18 +01:00
snipe fde6ff1571 Pint 2026-04-09 12:16:46 +01:00
snipe 8c9a48b38a Fixed custom field search nesting 2026-04-09 12:16:40 +01:00
snipe fff89ee94c Merge pull request #18852 from marcusmoore/fixes/rb-20840-bulk-asset-checkout
Fixed potential exception in bulk asset checkout
2026-04-09 11:28:42 +01:00
Marcus Moore 2745552915 Update asset factory 2026-04-08 14:05:26 -07:00
Marcus Moore 2f400a2b17 Handle target being assigned to a non-user model 2026-04-08 13:40:37 -07:00
Marcus Moore de5256b8f5 Use correct relationship 2026-04-08 13:36:22 -07:00
Marcus Moore 344ae053cf Add failing test 2026-04-08 13:35:11 -07:00
snipe 45fffd74b7 Merge pull request #18846 from grokability/strikethroug-if-component-is-deleted
Strikethrough if component is deleted
2026-04-08 12:47:49 +01:00
snipe a0cf0751de Pint 2026-04-08 12:39:38 +01:00
snipe 7485cb81aa Show strikethrough and unlink if item is deleted 2026-04-08 12:39:29 +01:00
snipe 7faa9a6fdf Fixed #18816 - updated language in acceptance email 2026-04-08 11:24:54 +01:00
snipe f6f7063419 Fixed #18844 - use correct component for bulk editing models on category detail view 2026-04-08 11:17:48 +01:00
snipe 1300fff94c Null safe operator for assets transformer 2026-04-08 11:11:55 +01:00
snipe 5ef9798c68 Pint 2026-04-08 10:09:56 +01:00
snipe db48c18766 Fixed #18840 - added print inventory button back to locations 2026-04-08 10:09:49 +01:00
snipe d6b48a2818 Merge pull request #18835 from uberbrady/improve_restore_port_numbers
Fixed #18786 - add port number option to the restore command
2026-04-07 15:48:20 +01:00
Brady Wetherington f8c7eee17b Add port number option to the restore command 2026-04-07 15:40:44 +01:00
snipe c8d2118c74 Merge pull request #18833 from uberbrady/new_indices_for_locations_query
Add new indexes to improve some Location queries
2026-04-07 15:31:32 +01:00
Brady Wetherington ddaa75a6dd Add new indexes to improve some Location queries 2026-04-07 15:23:31 +01:00
snipe 182e06173d Merge pull request #18730 from marcusmoore/laravel-12-take-2
Upgraded to Laravel 12
2026-04-06 13:03:39 +01:00
snipe f6b4600f8a Added checkout_class alias 2026-04-06 11:24:01 +01:00
Marcus Moore 6eaea0b73f Merge branch 'develop' into laravel-12-take-2 2026-03-23 10:29:49 -07:00
Marcus Moore ee7dddf836 Update league/commonmark 2026-03-23 10:29:44 -07:00
Marcus Moore e2e4743994 Attempt to handle docker build fail 2026-03-19 13:56:44 -07:00
Marcus Moore 7b1a5aea19 Revert "Allow installation when MAIL_FROM_ADDR is not set"
This reverts commit 602e13dab7.
2026-03-19 13:37:44 -07:00
Marcus Moore 602e13dab7 Allow installation when MAIL_FROM_ADDR is not set 2026-03-19 13:31:38 -07:00
Marcus Moore 64117b92b0 Revert "Add default address for docker"
This reverts commit 17c89a3f2b.
2026-03-19 13:19:23 -07:00
Marcus Moore 17c89a3f2b Add default address for docker 2026-03-19 12:07:24 -07:00
Brady Wetherington 9d33a2c524 Merge branch 'develop' into use_new_laravel_scim_server 2026-03-19 17:41:18 +00:00
Marcus Moore a470ba76df Remove assertion
Log::setEventDispatcher(Event::fake()) no longer works...
2026-03-18 14:04:17 -07:00
Marcus Moore 3ce017fa68 Upgrade to Laravel 12
Co-authored-by: Brady Wetherington <bwetherington@grokability.com>
2026-03-18 12:36:16 -07:00
Marcus Moore d446da2243 Update telescope 2026-03-18 12:23:46 -07:00
Marcus Moore cdb4416421 Update collision 2026-03-18 12:21:38 -07:00
Marcus Moore a1de8aa20c Bump phpunit to v11 2026-03-18 12:20:57 -07:00
Marcus Moore adfad90f7c Bump laravel-backup to v9
Co-authored-by: Brady Wetherington <bwetherington@grokability.com>
2026-03-18 12:17:46 -07:00
Marcus Moore 22703806cd Bump larastan to v3 2026-03-18 12:11:20 -07:00
Marcus Moore 22a63fc2ee Bump scim server 2026-03-18 12:10:33 -07:00
Godfrey M 8747ff32dd Merge branch 'develop' into update-print-invtentory-view-with-assigned2assets
# Conflicts:
#	app/Http/Controllers/ProfileController.php
#	app/Http/Controllers/Users/UsersController.php
2026-03-17 16:11:16 -07:00
ArturoSirvent 6145c6cc5a Fix backup disk driver configuration for S3 support
- Fix the backup disk in config/filesystems.php to use a dedicated BACKUP_FILESYSTEM_DRIVER env var instead of PRIVATE_FILESYSTEM_DISK
- Add AWS credential fields to the backup disk config so S3 backups work
- Use BACKUP_FILESYSTEM_ROOT with safe default (storage_path('app')) for local driver
- Document BACKUP_FILESYSTEM_DRIVER and BACKUP_FILESYSTEM_ROOT in .env.example

Fixes #14057
2026-03-14 23:24:58 +01:00
Godfrey M 4ddd2f1cf8 change indirect Asset name 2026-03-10 12:49:37 -07:00
Godfrey M 11c8fd4d4c update scope for directLicense.category" 2026-03-10 12:41:19 -07:00
Godfrey M ab04f3de93 use inventory scope, add quantity to print blade 2026-03-10 12:35:34 -07:00
Godfrey M 4c16796256 reduce query count to 52 2026-03-10 10:59:59 -07:00
Godfrey M 516771d948 update profile Controller print inventory 2026-03-10 09:50:00 -07:00
Godfrey M e25ea465c5 add ternary on variables in asset count" 2026-03-04 10:25:59 -08:00
Godfrey M 30ac3d1a26 fix display name of item" 2026-03-03 16:11:46 -08:00
Godfrey M e47c772230 cleaned up other tables in print view 2026-03-03 16:04:13 -08:00
Godfrey M 706b623d95 adds assets to indirect assignment table 2026-03-03 15:51:47 -08:00
Godfrey M a908a76f53 adds components to indirect assignment table 2026-03-03 15:15:45 -08:00
Godfrey M a2ec707f79 add licenses to indirect assignedment table 2026-03-03 12:53:37 -08:00
Brady Wetherington 7cbc0fa671 Merge branch 'develop' into use_new_laravel_scim_server 2026-02-26 19:53:05 +00:00
Brady Wetherington 15346eec22 WIP: cleaning up new SCIM config 2026-02-23 11:34:41 +00:00
Brady Wetherington c48e0c7377 Clean out fixme's, standardize on UpdatableComplex 2026-02-19 14:50:02 +00:00
Brady Wetherington 95fdfa6396 Merge branch 'develop' into use_new_laravel_scim_server 2026-02-19 12:41:43 +00:00
Brady Wetherington f8ecbf8f0b removing Log::error lines 2026-02-12 16:03:40 +00:00
Brady Wetherington c5ffbf6ed9 Merge branch 'develop' into use_new_laravel_scim_server 2026-02-11 12:53:42 +00:00
Brady Wetherington 2115de9926 WIP: move towards UpdatableComplex class for SCIM 2026-02-02 14:55:03 +00:00
Brady Wetherington 53149666ad Merge branch 'develop' into use_new_laravel_scim_server 2026-01-22 19:26:41 +00:00
Brady Wetherington 5d55c5021b Fix last of groups, phone numbers, etc. 2026-01-22 19:16:22 +00:00
Brady Wetherington 778da511a5 Merge branch 'develop' into use_new_laravel_scim_server 2026-01-14 15:38:34 +00:00
Brady Wetherington 84940f12c5 add fix to handling blank emails, add notes on things that look weird 2026-01-14 14:31:16 +00:00
Brady Wetherington 0f45ecc00f Merge branch 'develop' into use_new_laravel_scim_server 2026-01-13 13:31:15 +00:00
Brady Wetherington fc4ac029b1 Added the actual files to make that previous statement true 2025-09-29 12:56:59 +01:00
Brady Wetherington 73f4afa05e Got groups support working in Entra ID 2025-09-29 12:56:23 +01:00
Brady Wetherington ef1a42fff2 Progress! Got addresses updating correctly 2025-09-24 15:57:39 +01:00
Brady Wetherington 760d089073 Azure-specific Manager is handled now 2025-09-23 20:40:21 +01:00
Brady Wetherington 92fbf83bdb Adjusting some Schema settings to match our requirements 2025-09-16 15:41:14 +01:00
Brady Wetherington 9525bbf502 Re-worked the SCIMConfig for the new version of laravel-scim-server 2025-09-16 15:22:33 +01:00
Brady Wetherington 61df3bc462 WIP: switching to new version of laravel-scim-server 2025-09-16 11:44:10 +01:00
1544 changed files with 27525 additions and 8437 deletions
+9
View File
@@ -4262,6 +4262,15 @@
"contributions": [
"code"
]
},
{
"login": "Husky-Devel",
"name": "Peter Gallwas",
"avatar_url": "https://avatars.githubusercontent.com/u/75509373?v=4",
"profile": "https://www.husky.nz",
"contributions": [
"code"
]
}
]
}
+10 -1
View File
@@ -90,7 +90,16 @@ IMAGE_LIB=gd
# --------------------------------------------
# OPTIONAL: BACKUP SETTINGS
# --------------------------------------------
# Backup filesystem configuration
# - BACKUP_FILESYSTEM_DRIVER: Driver to use (local, s3, etc.)
# Default: local (backward compatible)
# Set to s3 to use S3 for backups (requires PRIVATE_AWS_* credentials)
# - BACKUP_FILESYSTEM_ROOT: Root path/prefix
# For local driver: leave commented for default to storage_path("app")
# For S3 driver: empty string = bucket root, or specify prefix like "backups/"
#--------------------------------------------
BACKUP_FILESYSTEM_DRIVER=local
#BACKUP_FILESYSTEM_ROOT=
MAIL_BACKUP_NOTIFICATION_DRIVER=null
MAIL_BACKUP_NOTIFICATION_ADDRESS=null
BACKUP_ENV=true
+69
View File
@@ -0,0 +1,69 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
# EthicalCheck provides the industrys only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list.
# Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities.
# You develop the application and API, we bring complete and continuous security testing to you, accelerating development.
# Know your API and Applications are secure with EthicalCheck our free & automated API security testing service.
# How EthicalCheck works?
# EthicalCheck functions in the following simple steps.
# 1. Security Testing.
# Provide your OpenAPI specification or start with a public Postman collection URL.
# EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing.
# It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc.
# 2. Reporting.
# EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities.
# Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging.
# This is a starter workflow to help you get started with EthicalCheck Actions
name: EthicalCheck-Workflow
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "master" branch
# Customize trigger events based on your DevSecOps processes.
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '35 17 * * 6'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
permissions:
contents: read
jobs:
Trigger_EthicalCheck:
permissions:
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
runs-on: ubuntu-latest
steps:
- name: EthicalCheck Free & Automated API Security Testing Service
uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641
with:
# The OpenAPI Specification URL or Swagger Path or Public Postman collection URL.
oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs"
# The email address to which the penetration test report will be sent.
email: "snipe@snipe.net"
sarif-result-file: "ethicalcheck-results.sarif"
- name: Upload sarif file to repository
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ./ethicalcheck-results.sarif
+1
View File
@@ -28,6 +28,7 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
- "8.5"
name: PHP ${{ matrix.php-version }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
- "8.5"
name: PHP ${{ matrix.php-version }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
fail-fast: false
matrix:
php-version:
- "8.3"
- "8.5"
name: PHP ${{ matrix.php-version }}
+8 -8
View File
@@ -1,10 +1,10 @@
{
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.4",
"php_max_wontwork": "8.5.0",
"current_snipeit_version": "8.0"
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.5",
"php_max_wontwork": "8.6.0",
"current_snipeit_version": "8.0"
}
+110
View File
@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Stack
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
## Common Commands
```bash
# Run all tests
php artisan test
# or
vendor/bin/phpunit
# Run a single test file
php artisan test tests/Feature/Assets/AssetsTest.php
# Run a specific test method
php artisan test --filter testSomeMethod
# Build frontend assets (dev)
npm run dev
# Build for production
npm run prod
# Laravel Mix watch
npm run watch
# Tinker / REPL
php artisan tinker
# Clear caches after config/route changes
php artisan optimize:clear
```
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
## Architecture
### Controllers
Two parallel controller trees:
- `app/Http/Controllers/` — web/UI controllers (Blade views)
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
### API Pattern
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
```php
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
```
### Authorization
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
### FMCS (Full Multiple Company Support)
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
```php
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$query->where('table.company_id', $request->input('companyId'));
}
```
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
### Select2 AJAX Dropdowns
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
### Routes
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
### Translations
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
### Checkout Redirect Flow
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
- Set `redirect_option=target` in the form
- Set `checkout_to_type=user` in the form
- Set `assigned_user={{ $user->id }}` in the form
### Key Helper Methods (`app/Helpers/Helper.php`)
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
- `Helper::defaultChartColors()` — 10-color palette used in charts
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
### Global View Variables
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
## Testing
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
+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/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
+15 -4
View File
@@ -10,9 +10,9 @@ however there are times when library dependencies and/or PHP/MySQL dependencies
make it impossible to backport security fixes on older versions.
| Version | Supported |
|---------| ------------------ |
|---------|--------------------|
| 8.x | :white_check_mark: |
| 7.x | :white_check_mark: |
| 7.x | :x: |
| 6.x | :x: |
| 5.1.x | :x: |
| 5.0.x | :x: |
@@ -24,7 +24,18 @@ make it impossible to backport security fixes on older versions.
Security vulnerabilities should be sent to security@snipeitapp.com. You can typically expect a
response within two business days, and we typically have fixes out in under a week from the initial disclosure.
This obviously varies based on the severity of the security issue and the difficulty in remediation,
but those have historically been the timelines we worm around.
This obviously varies based on the severity of the security issue and the difficulty in remediation, but those have
historically been the timelines we work around.
We do ask that you do not disclose the vulnerability publicly until we have had a chance to address it and tag a release
so that we can protect our users, and we will work
with you to coordinate a public disclosure once we have a fix out. We will also work with you to ensure that you receive
appropriate credit for the discovery of the vulnerability, if you would like to be credited. (Please provide a GitHub
username or other information if you would like to be credited, and please let us know if you would like to remain
anonymous.)
For responsible disclosure, we ask that you give us at least __90 days__ to address the issue before disclosing it
publicly,
but we will work with you if you need to disclose it sooner than that.
For a full breakdown of our security policies, please see https://snipeitapp.com/security.
@@ -0,0 +1,109 @@
<?php
namespace App\Actions\Breadcrumbs;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tabuna\Breadcrumbs\Trail;
final class BuildAcceptanceBreadcrumbs
{
public static function forAcceptance(Trail $trail, CheckoutAcceptance|int|string $acceptance): void
{
$acceptance = self::resolveAcceptance($acceptance);
$trail->parent('home');
if (! $acceptance instanceof CheckoutAcceptance) {
self::appendProfileContext($trail);
return;
}
if (! self::isSignInPlaceFlow($acceptance)) {
self::appendProfileContext($trail);
$trail->push(trans('general.accept_item'), route('account.accept.item', $acceptance));
return;
}
self::appendCheckoutFlowContext($trail, $acceptance);
$trail->push(self::buildSignInPlaceLabel($acceptance));
}
private static function resolveAcceptance(CheckoutAcceptance|int|string $acceptance): ?CheckoutAcceptance
{
if ($acceptance instanceof CheckoutAcceptance) {
return $acceptance;
}
if (is_numeric($acceptance)) {
return CheckoutAcceptance::find((int) $acceptance);
}
return null;
}
private static function isSignInPlaceFlow(CheckoutAcceptance $acceptance): bool
{
return (int) session('sign_in_place_acceptance_id') === (int) $acceptance->id;
}
private static function appendProfileContext(Trail $trail): void
{
$trail->push(trans('general.profile'), route('account'));
$trail->push(trans('general.accept_items'), route('account.accept'));
}
private static function appendCheckoutFlowContext(Trail $trail, CheckoutAcceptance $acceptance): void
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
$trail->push(trans('general.assets'), route('hardware.index'));
$trail->push($checkoutable->display_name ?? trans('general.asset'), route('hardware.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof LicenseSeat) {
$license = $checkoutable->license;
if ($license instanceof License) {
$trail->push(trans('general.licenses'), route('licenses.index'));
$trail->push($license->display_name ?? trans('general.license'), route('licenses.show', $license));
$trail->push(trans('general.checkout'));
}
return;
}
if ($checkoutable instanceof Consumable) {
$trail->push(trans('general.consumables'), route('consumables.index'));
$trail->push($checkoutable->display_name ?? trans('general.consumable'), route('consumables.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof Accessory) {
$trail->push(trans('general.accessories'), route('accessories.index'));
$trail->push($checkoutable->display_name ?? trans('general.accessory'), route('accessories.show', $checkoutable));
$trail->push(trans('general.checkout'));
}
}
private static function buildSignInPlaceLabel(CheckoutAcceptance $acceptance): string
{
if ($acceptance->assignedTo instanceof User) {
return sprintf('%s for %s', trans('general.sign_in_place'), $acceptance->assignedTo->display_name);
}
return trans('general.sign_in_place');
}
}
+63 -2
View File
@@ -19,7 +19,7 @@ class LdapSync extends Command
*
* @var string
*/
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--summary} {--json_summary}';
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--delete} {--summary} {--json_summary}';
/**
* The console command description.
@@ -94,6 +94,7 @@ class LdapSync extends Command
}
$summary = [];
$seen_ldap_usernames = [];
try {
@@ -274,8 +275,14 @@ class LdapSync extends Command
'name' => $item['department'],
]);
$user = User::where('username', $item['username'])->first();
$user = User::withTrashed()->where('username', $item['username'])->first();
if (! empty($item['username'])) {
$seen_ldap_usernames[] = $item['username'];
}
if ($user) {
if ($user->trashed()) {
$user->restore();
}
// Updating an existing user.
$item['createorupdate'] = 'updated';
} else {
@@ -490,6 +497,41 @@ class LdapSync extends Command
array_push($summary, $item);
}
// Optionally soft-delete LDAP-imported users that are no longer present in LDAP.
// users with assests etc. are not deletable and skipped
if ($this->option('delete')) {
$missing_ldap_users = User::where('ldap_import', 1);
$missing_ldap_users = $missing_ldap_users->whereNotIn('username', $seen_ldap_usernames);
$missing_ldap_users = $missing_ldap_users->get();
foreach ($missing_ldap_users as $missing_user) {
$is_deletable = $this->isUserDeletable($missing_user);
$missing_item = [
'id' => $missing_user->id,
'username' => $missing_user->username,
'firstname' => $missing_user->first_name,
'lastname' => $missing_user->last_name,
'email' => $missing_user->email,
'createorupdate' => 'skipped',
'status' => 'info',
'deletable' => $is_deletable,
'note' => $is_deletable ? 'missing from LDAP' : 'missing from LDAP, but not deletable',
];
if ($is_deletable) {
$missing_user->delete();
$missing_item['createorupdate'] = 'deleted';
$missing_item['status'] = 'success';
$missing_item['note'] = 'deleted_missing_from_ldap';
}
$summary[] = $missing_item;
}
}
if ($this->option('summary')) {
for ($x = 0; $x < count($summary); $x++) {
if ($summary[$x]['status'] == 'error') {
@@ -505,4 +547,23 @@ class LdapSync extends Command
return $summary;
}
}
/**
* Checks if the user is deletable without gate check
*
* A user is considered deletable if they have no associated assets, accessories, licenses, consumables, managed users, or managed locations.
*
* @param User $user The user to check
*
* @return bool True if the user is deletable, false otherwise
*/
private function isUserDeletable(User $user): bool
{
return (($user->assets_count ?? $user->assets()->count()) === 0)
&& (($user->accessories_count ?? $user->accessories()->count()) === 0)
&& (($user->licenses_count ?? $user->licenses()->count()) === 0)
&& (($user->consumables_count ?? $user->consumables()->count()) === 0)
&& (($user->manages_users_count ?? $user->managesUsers()->count()) === 0)
&& (($user->manages_locations_count ?? $user->managedLocations()->count()) === 0);
}
}
+31 -2
View File
@@ -15,7 +15,9 @@ class PurgeEulaPDFs extends Command
* @var string
*/
protected $signature = 'snipeit:purge-eula-pdfs
{--older-than-days= : The number of days we should delete before }
{--older-than-days= : The number of days we should delete before }
{--company-id= : Only purge acceptances for users in this company}
{--only-deleted-users : Only purge acceptances for deleted users, including soft-deleted or missing users}
{--force : Skip the interactive yes/no prompt for confirmation}
{--dryrun : Show the records that would be deleted but don\'t update the database or delete files from disk}
{--with-output : Display the results in a table in your console}';
@@ -55,7 +57,34 @@ class PurgeEulaPDFs extends Command
$this->info('This script is being run with the --dryrun option. No files or records will be deleted.');
}
$acceptances = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)->with('assignedTo')->get();
$companyId = $this->option('company-id');
$query = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)
->with([
'assignedTo' => function ($query) {
$query->withTrashed();
},
]);
if ($this->option('only-deleted-users')) {
$query->where(function ($query) use ($companyId) {
$query->whereHas('assignedTo', function ($q) use ($companyId) {
$q->withTrashed()->whereNotNull('deleted_at');
if ($companyId) {
$q->where('company_id', $companyId);
}
});
$query->orWhereDoesntHave('assignedTo');
});
} else {
if ($companyId) {
$query->whereHas('assignedTo', function ($query) use ($companyId) {
$query->withTrashed()->where('company_id', $companyId);
});
}
}
$acceptances = $query->get();
if (! $this->option('force')) {
if ($this->confirm("\n****************************************************\nTHIS WILL DELETE ALL OF THE SIGNATURES AND EULA PDF FILES SINCE $interval_date. \nThere is NO undo! \n****************************************************\n\nDo you wish to continue? No backsies! [y|N]")) {
+5 -1
View File
@@ -456,7 +456,11 @@ class RestoreFromBackup extends Command
if (! file_exists($mysql_binary)) {
return $this->error("mysql tool at: '$mysql_binary' does not exist, cannot restore. Please edit DB_DUMP_PATH in your .env to point to a directory that contains the mysqldump and mysql binary");
}
$proc_results = proc_open("$mysql_binary -h ".escapeshellarg(config('database.connections.mysql.host')).' -u '.escapeshellarg(config('database.connections.mysql.username')).' '.escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
$proc_results = proc_open("$mysql_binary -h " .
escapeshellarg(config('database.connections.mysql.host')) .
' -u ' . escapeshellarg(config('database.connections.mysql.username')) . ' ' .
' -P ' . escapeshellarg(config('database.connections.mysql.port')) . ' ' .
escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
null,
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Asset;
use Illuminate\Console\Command;
use Illuminate\Support\MessageBag;
class ValidateAssets extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:validate-assets {--all : Display the valid assets in your table output as well} ';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This runs through the list of assets and checks for any validation errors that would prevent it from being updated or checked in or out. ';
/**
* Execute the console command.
*/
public function handle()
{
$showAll = (bool) $this->option('all');
$assets = Asset::query()
->whereNull('deleted_at')
->with('model')
->orderBy('assets.created_at', 'desc')
->get();
if (! $showAll) {
$this->info('Run this command with the --all option to see the full list in the console.');
}
$rows = $assets
->filter(fn (Asset $asset) => $showAll || ! $asset->isValid())
->map(fn (Asset $asset) => [
trans('general.id') => $asset->id,
trans('admin/hardware/form.tag') => $asset->asset_tag,
trans('admin/hardware/form.serial') => $asset->serial ?? '',
trans('admin/hardware/form.model') => $asset->model?->name ?? '',
trans('general.model_no') => $asset->model?->model_number ?? '',
trans('general.error') => $asset->isValid() ? '√ valid' : $this->formatValidationErrors($asset),
])
->values()
->all();
$this->table(
[
trans('general.id'),
trans('admin/hardware/form.tag'),
trans('admin/hardware/form.serial'),
trans('admin/hardware/form.model'),
trans('general.model_no'),
trans('general.error'),
],
$rows
);
return self::SUCCESS;
}
private function formatValidationErrors(Asset $asset): string
{
$errors = $asset->getErrors();
$messages = [];
if ($errors instanceof MessageBag) {
$messages = $errors->all();
} elseif (is_array($errors)) {
$messages = $errors;
} else {
$messages = [(string) $errors];
}
$prefixedMessages = collect($messages)
->map(fn ($message) => trim((string) $message))
->filter()
->map(fn (string $message) => str_starts_with($message, '✘') ? $message : '✘ '.$message)
->values()
->all();
return implode(PHP_EOL, $prefixedMessages);
}
}
+3
View File
@@ -13,6 +13,7 @@ enum ActionType: string
// Assets/Accessories/Components/Licenses/Consumables
case Checkout = 'checkout';
case CheckinFrom = 'checkin from';
case ForceCheckin = 'force checkin';
case Requested = 'requested';
case RequestCanceled = 'request canceled';
case Accepted = 'accepted';
@@ -23,6 +24,8 @@ enum ActionType: string
// Users
case TwoFactorReset = '2FA reset';
case Merged = 'merged';
case TokenRevoked = 'token revoked';
case TokenUnrevoked = 'token unrevoked';
// Licenses
case DeleteSeats = 'delete seats';
+4 -1
View File
@@ -22,12 +22,14 @@ class CheckoutableCheckedOut
public int $quantity;
public bool $signInPlace;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1)
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1, bool $signInPlace = false)
{
$this->checkoutable = $checkoutable;
$this->checkedOutTo = $checkedOutTo;
@@ -35,5 +37,6 @@ class CheckoutableCheckedOut
$this->note = $note;
$this->originalValues = $originalValues;
$this->quantity = $quantity;
$this->signInPlace = $signInPlace;
}
}
+1 -1
View File
@@ -201,7 +201,7 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthorized or unauthenticated.'], 401);
return response()->json(['error' => trans('general.unauthorized')], 401);
}
return redirect()->guest('login');
+13 -3
View File
@@ -1629,10 +1629,20 @@ class Helper
// return to assignment target
if ($redirect_option == 'target') {
$userId = $request->assigned_user ?? $checkedInFrom;
$locationId = $request->assigned_location ?? $checkedInFrom;
$assetId = $request->assigned_asset ?? $checkedInFrom;
return match ($checkout_to_type) {
'user' => redirect()->route('users.show', $request->assigned_user ?? $checkedInFrom),
'location' => redirect()->route('locations.show', $request->assigned_location ?? $checkedInFrom),
'asset' => redirect()->route('hardware.show', $request->assigned_asset ?? $checkedInFrom),
'user' => $userId
? redirect()->route('users.show', $userId)
: redirect()->route('users.index'),
'location' => $locationId
? redirect()->route('locations.show', $locationId)
: redirect()->route('locations.index'),
'asset' => $assetId
? redirect()->route('hardware.show', $assetId)
: redirect()->route('hardware.index'),
};
}
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
@@ -88,12 +89,53 @@ class AccessoryCheckoutController extends Controller
$request->input('note'),
[],
$accessory->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
$request->request->add(['assigned_to' => $target->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested for a user checkout, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && ! in_array($request->input('checkout_to_type'), ['asset', 'location'], true)) {
$targetUser = User::find($target->id);
if (! $targetUser instanceof User) {
return redirect()->route('accessories.checkout.show', $accessory)
->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
}
$acceptance = CheckoutAcceptance::where('checkoutable_type', Accessory::class)
->where('checkoutable_id', $accessory->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($accessory);
$acceptance->assignedTo()->associate($targetUser);
$acceptance->qty = $accessory->checkout_qty;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $accessory->id,
'sign_in_place_resource_type' => 'Accessories',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/accessories/message.checkout.success'));
}
// Redirect to the new accessory page
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
@@ -7,8 +7,14 @@ use App\Events\CheckoutDeclined;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Mail\CheckoutAcceptanceResponseMail;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\AcceptanceItemAcceptedNotification;
@@ -40,19 +46,32 @@ class AcceptanceController extends Controller
*
* @param int $id
*/
public function create($id): View|RedirectResponse
public function create(Request $request, $id): View|RedirectResponse
{
$currentUser = auth()->user();
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (is_null($acceptance)) {
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
}
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
@@ -60,7 +79,10 @@ class AcceptanceController extends Controller
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
}
return view('account/accept.create', compact('acceptance'));
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
$checkedOutBy = $this->resolveCheckoutActorName($acceptance);
return view('account/accept.create', compact('acceptance', 'isSignInPlaceAdminFlow', 'checkedOutAt', 'checkedOutBy'));
}
/**
@@ -70,20 +92,36 @@ class AcceptanceController extends Controller
*/
public function store(Request $request, $id): RedirectResponse
{
$currentUser = auth()->user();
if (! $acceptance = CheckoutAcceptance::find($id)) {
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
}
$assigned_user = User::find($acceptance->assigned_to_id);
$assignedUser = User::find($acceptance->assigned_to_id);
$settings = Setting::getSettings();
$requiresSignature = (string) $settings->require_accept_signature === '1';
$sig_filename = '';
$encodedSignatureImage = null;
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
@@ -112,14 +150,25 @@ class AcceptanceController extends Controller
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
// If signatures are required, make sure we have one
if (Setting::getSettings()->require_accept_signature == '1') {
if ($requiresSignature) {
// The item was accepted, check for a signature
if ($request->filled('signature_output')) {
$sig_filename = 'siglog-'.Str::uuid().'-'.date('Y-m-d-his').'.png';
$data_uri = $request->input('signature_output');
$encoded_image = explode(',', $data_uri);
$decoded_image = base64_decode($encoded_image[1]);
$dataUri = (string) $request->input('signature_output');
$encodedSignatureImage = Str::contains($dataUri, ',')
? Str::after($dataUri, ',')
: $dataUri;
$decoded_image = base64_decode($encodedSignatureImage, true);
if ($decoded_image === false) {
return redirect()->back()->with('error', trans('general.shitty_browser'));
}
$decoded_image = $this->flattenSignatureBackgroundToWhite($decoded_image);
$encodedSignatureImage = base64_encode($decoded_image);
Storage::put('private_uploads/signatures/'.$sig_filename, (string) $decoded_image);
// No image data is present, kick them back.
@@ -133,7 +182,7 @@ class AcceptanceController extends Controller
// This is needed for TCPDF to properly embed the image if it's a png and the cache isn't writable
$encoded_logo = null;
if (($settings->acceptance_pdf_logo) && (Storage::disk('public')->exists($settings->acceptance_pdf_logo))) {
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.$settings->acceptance_pdf_logo));
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
}
// Get the data array ready for the notifications and PDF generation
@@ -148,18 +197,42 @@ class AcceptanceController extends Controller
'check_out_date' => Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false),
'accepted_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'declined_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'assigned_to' => $assigned_user->display_name,
'email' => $assigned_user->email,
'employee_num' => $assigned_user->employee_num,
'assigned_to' => $assignedUser->display_name,
'email' => $assignedUser->email,
'employee_num' => $assignedUser->employee_num,
'site_name' => $settings->site_name,
'company_name' => $item->company?->name ?? $settings->site_name,
'signature' => (($sig_filename && array_key_exists('1', $encoded_image))) ? $encoded_image[1] : null,
'signature' => ($sig_filename !== '') ? $encodedSignatureImage : null,
'logo' => ($encoded_logo) ?? null,
'date_settings' => $settings->date_display_format,
'qty' => $acceptance->qty ?? 1,
];
if ($request->input('asset_acceptance') == 'accepted') {
// Include asset custom fields that are explicitly allowed in outbound emails/PDFs.
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
$customFields = [];
$fields = $item->model->fieldset->fields
->where('show_in_email', true)
->where('field_encrypted', false);
foreach ($fields as $field) {
$dbColumn = $field->db_column;
$value = $item->{$dbColumn};
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $field->name,
'value' => $value,
];
}
}
if (! empty($customFields)) {
$data['custom_fields'] = $customFields;
}
}
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
@@ -171,12 +244,12 @@ class AcceptanceController extends Controller
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
// Send the PDF to the signing user
if (($request->input('send_copy') == '1') && ($assigned_user->email != '')) {
if (($request->input('send_copy') === '1') && ($assignedUser->email !== '')) {
// Add the attachment for the signing user into the $data array
$data['file'] = $pdf_filename;
try {
$assigned_user->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assigned_user->locale));
$assignedUser->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assignedUser->locale));
} catch (Exception $e) {
Log::warning($e);
}
@@ -215,7 +288,7 @@ class AcceptanceController extends Controller
$recipient,
$request->input('asset_acceptance') === 'accepted',
));
Log::debug('Send email notification sucess on checkout acceptance response.');
Log::debug('Send email notification success on checkout acceptance response.');
}
} catch (Exception $e) {
Log::error($e->getMessage());
@@ -223,7 +296,163 @@ class AcceptanceController extends Controller
}
}
if ($isSignInPlaceAdminFlow) {
$request->request->add(['assigned_user' => $assignedUser?->id]);
$redirect = Helper::getRedirectOption(
$request,
session('sign_in_place_item_id'),
session('sign_in_place_resource_type'),
);
session()->forget([
'sign_in_place_acceptance_id',
'sign_in_place_item_id',
'sign_in_place_resource_type',
]);
return $redirect->with('success', $return_msg);
}
return redirect()->to('account/accept')->with('success', $return_msg);
}
private function isSignInPlaceAdminFlow(CheckoutAcceptance $acceptance): bool
{
$currentUser = auth()->user();
return ((int) session('sign_in_place_acceptance_id') === (int) $acceptance->id)
&& ($currentUser?->can('checkout', $acceptance->checkoutable));
}
private function resolveCheckoutActorName(CheckoutAcceptance $acceptance): ?string
{
[$itemType, $itemId] = $this->resolveCheckoutLogItem($acceptance);
$checkoutLog = Actionlog::query()
->where('action_type', 'checkout')
->where('item_type', $itemType)
->where('item_id', $itemId)
->where('target_type', User::class)
->where('target_id', $acceptance->assigned_to_id)
->where('created_at', '<=', $acceptance->created_at->copy()->addMinutes(5))
->latest('id')
->first();
return $checkoutLog?->adminuser?->display_name;
}
/**
* Action logs normalize license seat checkouts to the parent license.
*
* @return array{0: class-string, 1: int}
*/
private function resolveCheckoutLogItem(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof LicenseSeat) {
return [License::class, (int) $checkoutable->license_id];
}
return [$acceptance->checkoutable_type, (int) $acceptance->checkoutable_id];
}
private function isStaleSignInPlaceAdminAttempt(CheckoutAcceptance $acceptance, User $currentUser): bool
{
$redirectOption = session('redirect_option');
$checkoutToType = session('checkout_to_type');
if (session('sign_in_place') !== true) {
return false;
}
if ($redirectOption === null) {
return false;
}
if ($redirectOption === 'target' && $checkoutToType === 'user' && empty($acceptance->assigned_to_id)) {
return false;
}
return ! $acceptance->isCheckedOutTo($currentUser)
&& $currentUser->can('checkout', $acceptance->checkoutable)
&& ($checkoutToType === 'user');
}
private function redirectToIntendedSignInPlaceDestination(Request $request, CheckoutAcceptance $acceptance): RedirectResponse
{
if (empty($acceptance->assigned_to_id)) {
return redirect()->route('account.accept');
}
[$itemId, $resourceType] = $this->resolveRedirectTarget($acceptance);
$request->request->add(['assigned_user' => $acceptance->assigned_to_id]);
return Helper::getRedirectOption($request, $itemId, $resourceType);
}
/**
* @return array{0: int, 1: string}
*/
private function resolveRedirectTarget(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
return [(int) $checkoutable->id, 'Assets'];
}
if ($checkoutable instanceof Accessory) {
return [(int) $checkoutable->id, 'Accessories'];
}
if ($checkoutable instanceof Consumable) {
return [(int) $checkoutable->id, 'Consumables'];
}
if ($checkoutable instanceof LicenseSeat) {
return [(int) $checkoutable->license_id, 'Licenses'];
}
return [(int) $acceptance->checkoutable_id, session('sign_in_place_resource_type', 'Assets')];
}
private function flattenSignatureBackgroundToWhite(string $signatureBinary): string
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagecreatetruecolor')) {
return $signatureBinary;
}
$source = @imagecreatefromstring($signatureBinary);
if ($source === false) {
return $signatureBinary;
}
$width = imagesx($source);
$height = imagesy($source);
$flattened = imagecreatetruecolor($width, $height);
if ($flattened === false) {
imagedestroy($source);
return $signatureBinary;
}
$white = imagecolorallocate($flattened, 255, 255, 255);
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
imagecopy($flattened, $source, 0, 0, 0, 0, $width, $height);
ob_start();
imagepng($flattened);
$output = ob_get_clean();
imagedestroy($source);
imagedestroy($flattened);
return is_string($output) ? $output : $signatureBinary;
}
}
@@ -15,6 +15,8 @@ class ActionlogController extends Controller
{
public function displaySig($filename): RedirectResponse|Response|bool
{
$filename = basename((string) $filename);
// 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);
@@ -44,6 +46,7 @@ class ActionlogController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
if ($actionlog = Actionlog::where('filename', $filename)->with('user')->with('target')->firstOrFail()) {
@@ -14,11 +14,14 @@ use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Company;
use App\Models\Setting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class AccessoriesController extends Controller
{
@@ -84,23 +87,23 @@ class AccessoriesController extends Controller
}
if ($request->filled('category_id')) {
$accessories->where('category_id', '=', $request->input('category_id'));
$accessories->where('accessories.category_id', '=', $request->input('category_id'));
}
if ($request->filled('manufacturer_id')) {
$accessories->where('manufacturer_id', '=', $request->input('manufacturer_id'));
$accessories->where('accessories.manufacturer_id', '=', $request->input('manufacturer_id'));
}
if ($request->filled('supplier_id')) {
$accessories->where('supplier_id', '=', $request->input('supplier_id'));
$accessories->where('accessories.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('location_id')) {
$accessories->where('location_id', '=', $request->input('location_id'));
$accessories->where('accessories.location_id', '=', $request->input('location_id'));
}
if ($request->filled('notes')) {
$accessories->where('notes', '=', $request->input('notes'));
$accessories->where('accessories.notes', '=', $request->input('notes'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -155,6 +158,7 @@ class AccessoriesController extends Controller
{
$accessory = new Accessory;
$accessory->fill($request->all());
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$accessory = $request->handleImages($accessory);
if ($accessory->save()) {
@@ -248,6 +252,7 @@ class AccessoriesController extends Controller
$this->authorize('update', Accessory::class);
$accessory = Accessory::findOrFail($id);
$accessory->fill($request->all());
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$accessory = $request->handleImages($accessory);
if ($accessory->save()) {
@@ -297,40 +302,49 @@ class AccessoriesController extends Controller
{
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
$accessory->checkout_qty = $request->input('checkout_qty', 1);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Set this value to be able to pass the qty through to the event
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
$accessory->checkout_qty = $request->input('checkout_qty', 1);
$payload = null;
// Keep checkout rows and checkout log/event atomic to avoid ghost assignments.
DB::transaction(function () use ($accessory, $request, $target, &$payload): void {
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
}
// Set this value to be able to pass the qty through to the event.
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
@@ -405,11 +419,11 @@ class AccessoriesController extends Controller
public function history(Request $request, Accessory $accessory): JsonResponse|array
{
$this->authorize('history', $accessory);
$history = $accessory->getHistory($request);
$total = $accessory->getHistory($request)->count();
$historyQuery = $accessory->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -343,11 +343,11 @@ class AssetModelsController extends Controller
public function history(Request $request, AssetModel $model): JsonResponse|array
{
$this->authorize('history', $model);
$history = $model->getHistory($request);
$total = $model->getHistory($request)->count();
$historyQuery = $model->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
+173 -39
View File
@@ -39,6 +39,7 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class controls all actions related to assets for
@@ -219,10 +220,18 @@ class AssetsController extends Controller
// This is used by the sidenav, mostly
// We switched from using query scopes here because of a Laravel bug
// related to fulltext searches on complex queries.
// I am sad. :(
switch ($request->input('status_type')) {
// This bit here accounts for folks actually using the formerly-known-as status like we previously used in the sidenav
// to return a list of all assets with the status *type* of Deployed, etc. The inuput field used to be "status" (which was consistent
// with the relation rename, but it broke the sidebar. This should handle both use cases in the event that someone didn't update
// their API integration code
$status_type_key = null;
if ($request->filled('status_type')) {
$status_type_key = $request->input('status_type');
} elseif ($request->filled('status')) {
$status_type_key = $request->input('status');
}
switch ($status_type_key) {
case 'Deleted':
$assets->onlyTrashed();
break;
@@ -697,18 +706,35 @@ class AssetsController extends Controller
}
}
if ($asset->save()) {
if ($request->input('assigned_user')) {
$target = User::find(request('assigned_user'));
} elseif ($request->input('assigned_asset')) {
$target = Asset::find(request('assigned_asset'));
} elseif ($request->input('assigned_location')) {
$target = Location::find(request('assigned_location'));
$target = $this->resolveCheckoutTargetForAssetMutation($request);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if ($requestedCheckout) {
// Keep create + optional checkout side effects atomic.
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
return true;
});
if ($stored) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -783,25 +809,54 @@ class AssetsController extends Controller
}
}
}
if ($asset->save()) {
if (($request->filled('assigned_user')) && ($target = User::find($request->input('assigned_user')))) {
$location = $target->location_id;
} elseif (($request->filled('assigned_asset')) && ($target = Asset::find($request->input('assigned_asset')))) {
$location = $target->location_id;
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
} elseif (($request->filled('assigned_location')) && ($target = Location::find($request->input('assigned_location')))) {
$location = $target->id;
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
}
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if (isset($target)) {
if ($requestedCheckout) {
// Using `->has` preserves the asset name if the name parameter was not included in request.
$asset_name = request()->has('name') ? request('name') : $asset->name;
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
$location = null;
if ($request->filled('assigned_user')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_asset')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_location')) {
$location = $target->id;
}
// Keep update + optional checkout side effects atomic.
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
return false;
}
if ($request->filled('assigned_asset')) {
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
}
}
return true;
});
if ($updated) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -820,6 +875,36 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
}
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
{
if ($request->filled('assigned_user')) {
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
}
if ($request->filled('assigned_asset')) {
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
}
if ($request->filled('assigned_location')) {
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
}
return null;
}
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($target->company_id))
&& ((int) $asset->company_id !== (int) $target->company_id)
) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
return null;
}
/**
* Delete a given asset (mark as deleted).
*
@@ -896,6 +981,7 @@ class AssetsController extends Controller
*/
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
{
// Use the same hardened checkout path as ID-based checkout.
if ($asset = Asset::where('asset_tag', $tag)->first()) {
return $this->checkout($request, $asset->id);
}
@@ -931,19 +1017,22 @@ class AssetsController extends Controller
// This item is checked out to a location
if (request('checkout_to_type') == 'location') {
$target = Location::find(request('assigned_location'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
$asset->location_id = ($target) ? $target->id : '';
$error_payload['target_id'] = $request->input('assigned_location');
$error_payload['target_type'] = 'location';
} elseif (request('checkout_to_type') == 'asset') {
$target = Asset::where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Override with the asset's location_id if it has one
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_asset');
$error_payload['target_type'] = 'asset';
} elseif (request('checkout_to_type') == 'user') {
// Fetch the target and set the asset's new location_id
$target = User::find(request('assigned_user'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_user');
$error_payload['target_type'] = 'user';
@@ -953,10 +1042,25 @@ class AssetsController extends Controller
$asset->status_id = $request->input('status_id');
}
// Preserve existing requestable state unless API caller explicitly includes the field.
if ($request->has('requestable')) {
$asset->requestable = $request->boolean('requestable');
}
if (! isset($target)) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
$targetCompanyId = data_get($target, 'company_id');
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($targetCompanyId))
&& ((int) $asset->company_id !== (int) $targetCompanyId)
) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, trans('general.error_user_company')));
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
$expected_checkin = request('expected_checkin', null);
$note = request('note', null);
@@ -971,7 +1075,12 @@ class AssetsController extends Controller
// $asset->location_id = $target->rtd_location_id;
// }
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
// Keep checkout mutation + checkout logging/event side effects atomic.
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
});
if ($wasCheckedOut) {
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
}
@@ -1053,6 +1162,12 @@ class AssetsController extends Controller
});
if ($asset->save()) {
// Update the location of any child assets
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
return response()->json(Helper::formatStandardApiResponse('success', [
@@ -1109,11 +1224,23 @@ class AssetsController extends Controller
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
}
// Allow the asset tag to be passed in the payload (legacy method)
if ($request->filled('asset_tag')) {
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
$audit_key = $request->input('audit_key', null);
// If they have selected to scan by serial, use that
if (($settings->unique_serial == '1') && ($audit_by_field == 'serial') && ($audit_key)) {
$asset = Asset::where('serial', '=', trim($audit_key))->first();
// If they have selected by asset tag, use that
} elseif (($audit_by_field == 'asset_tag') && ($audit_key)) {
$asset = Asset::where('asset_tag', '=', trim($audit_key))->first();
// Allow the asset tag to be passed in the payload (legacy method)
} elseif ($request->filled('asset_tag')) {
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
}
// If none of the above were selected, fall back to the route-model-binding
if ($asset) {
$originalValues = $asset->getRawOriginal();
@@ -1135,8 +1262,10 @@ class AssetsController extends Controller
// Set up the payload for re-display in the API response
$payload = [
'id' => $asset->id,
'asset_tag' => $asset->asset_tag,
'note' => e($request->input('note')),
'asset_tag' => e($asset->asset_tag),
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
'note' => $request->filled('note') ? e($request->input('note')) : null,
'status_label' => e($asset->status?->display_name),
'status_type' => $asset->status?->getStatuslabelType(),
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
@@ -1177,7 +1306,7 @@ class AssetsController extends Controller
// Validate the rest of the data before we turn off the event dispatcher
if ($asset->isInvalid()) {
return response()->json(Helper::formatStandardApiResponse('error', ['asset_tag' => $asset->asset_tag], $asset->getErrors()));
return response()->json(Helper::formatStandardApiResponse('error', $payload, $asset->getErrors()));
}
/**
@@ -1210,8 +1339,13 @@ class AssetsController extends Controller
}
$fail_payload = [
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
];
// No matching asset for the asset tag that was passed.
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
return response()->json(Helper::formatStandardApiResponse('error', $fail_payload, trans('admin/hardware/message.does_not_exist')), 200);
}
@@ -1411,7 +1545,7 @@ class AssetsController extends Controller
$label = new Label;
if (! $label) {
throw new \Exception('Label object could not be created');
throw new \Exception(trans('admin/labels/message.label_not_created'));
}
// Configure label with assets and settings
@@ -1432,7 +1566,7 @@ class AssetsController extends Controller
// Verify PDF was generated successfully
if (empty($pdf_content)) {
throw new \Exception('PDF content is empty');
throw new \Exception(trans('admin/labels/message.use_new_label_engine_for_api'));
}
$encoded_content = base64_encode($pdf_content);
@@ -1460,11 +1594,11 @@ class AssetsController extends Controller
public function history(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('history', $asset);
$history = $asset->getHistory($request);
$total = $asset->getHistory($request)->count();
$historyQuery = $asset->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -129,6 +129,11 @@ class CategoriesController extends Controller
case 'created_by':
$categories = $categories->OrderByCreatedBy($order);
break;
// This is annoying, since it's not a real relationship, which is what we usually use these switches for, but
// we call the field has_eula, not eula_text, so there won't be a matching field
case 'has_eula':
$categories = $categories->orderBy('eula_text', $order);
break;
default:
$categories = $categories->orderBy($column_sort, $order);
break;
@@ -9,8 +9,9 @@ use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\ComponentsTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Component;
use App\Models\ComponentAssignment;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
@@ -80,7 +81,7 @@ class ComponentsController extends Controller
}
if ($request->filled('name')) {
$components->where('name', '=', $request->input('name'));
$components->where('components.name', '=', $request->input('name'));
}
if ($request->filled('company_id')) {
@@ -92,27 +93,27 @@ class ComponentsController extends Controller
}
if ($request->filled('category_id')) {
$components->where('category_id', '=', $request->input('category_id'));
$components->where('components.category_id', '=', $request->input('category_id'));
}
if ($request->filled('supplier_id')) {
$components->where('supplier_id', '=', $request->input('supplier_id'));
$components->where('components.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('manufacturer_id')) {
$components->where('manufacturer_id', '=', $request->input('manufacturer_id'));
$components->where('components.manufacturer_id', '=', $request->input('manufacturer_id'));
}
if ($request->filled('model_number')) {
$components->where('model_number', '=', $request->input('model_number'));
$components->where('components.model_number', '=', $request->input('model_number'));
}
if ($request->filled('location_id')) {
$components->where('location_id', '=', $request->input('location_id'));
$components->where('components.location_id', '=', $request->input('location_id'));
}
if ($request->filled('notes')) {
$components->where('notes', '=', $request->input('notes'));
$components->where('components.notes', '=', $request->input('notes'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -166,6 +167,7 @@ class ComponentsController extends Controller
$this->authorize('create', Component::class);
$component = new Component;
$component->fill($request->all());
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$component = $request->handleImages($component);
if ($component->save()) {
@@ -206,6 +208,7 @@ class ComponentsController extends Controller
$this->authorize('update', Component::class);
$component = Component::findOrFail($id);
$component->fill($request->all());
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$component = $request->handleImages($component);
if ($component->save()) {
@@ -252,13 +255,11 @@ class ComponentsController extends Controller
{
$this->authorize('view', Asset::class);
$component_checkouts = ComponentAssignment::where('component_id', $component->id)->with('adminuser')->with('assets');
$offset = request('offset', 0);
$limit = $request->input('limit', 50);
if ($request->filled('search')) {
$assets = $component_checkouts->assets()
$assets = $component->assets()
->where(function ($query) use ($request) {
$search_str = '%'.$request->input('search').'%';
$query->where('name', 'like', $search_str)
@@ -314,20 +315,33 @@ class ComponentsController extends Controller
}
if ($component->numRemaining() >= $request->input('assigned_qty')) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company records and lead to partial writes.
$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));
$asset = Asset::find($request->input('assigned_to'));
$component->assigned_to = $request->input('assigned_to');
if (! $asset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($component->company_id !== $asset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
// Keep pivot + action log in one transaction so checkout is all-or-nothing.
DB::transaction(function () use ($component, $request, $asset): void {
$component->assigned_to = $request->input('assigned_to');
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
}
@@ -391,11 +405,11 @@ class ComponentsController extends Controller
public function history(Request $request, Component $component): JsonResponse|array
{
$this->authorize('history', $component);
$history = $component->getHistory($request);
$total = $component->getHistory($request)->count();
$historyQuery = $component->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -13,9 +13,11 @@ use App\Http\Transformers\ConsumablesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ConsumablesController extends Controller
{
@@ -67,7 +69,7 @@ class ConsumablesController extends Controller
}
if ($request->filled('name')) {
$consumables->where('name', '=', $request->input('name'));
$consumables->where('consumables.name', '=', $request->input('name'));
}
if ($request->filled('company_id')) {
@@ -79,27 +81,27 @@ class ConsumablesController extends Controller
}
if ($request->filled('category_id')) {
$consumables->where('category_id', '=', $request->input('category_id'));
$consumables->where('consumables.category_id', '=', $request->input('category_id'));
}
if ($request->filled('model_number')) {
$consumables->where('model_number', '=', $request->input('model_number'));
$consumables->where('consumables.model_number', '=', $request->input('model_number'));
}
if ($request->filled('manufacturer_id')) {
$consumables->where('manufacturer_id', '=', $request->input('manufacturer_id'));
$consumables->where('consumables.manufacturer_id', '=', $request->input('manufacturer_id'));
}
if ($request->filled('supplier_id')) {
$consumables->where('supplier_id', '=', $request->input('supplier_id'));
$consumables->where('consumables.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('location_id')) {
$consumables->where('location_id', '=', $request->input('location_id'));
$consumables->where('consumables.location_id', '=', $request->input('location_id'));
}
if ($request->filled('notes')) {
$consumables->where('notes', '=', $request->input('notes'));
$consumables->where('consumables.notes', '=', $request->input('notes'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -155,6 +157,7 @@ class ConsumablesController extends Controller
$this->authorize('create', Consumable::class);
$consumable = new Consumable;
$consumable->fill($request->all());
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$consumable = $request->handleImages($consumable);
if ($consumable->save()) {
@@ -194,6 +197,7 @@ class ConsumablesController extends Controller
$this->authorize('update', Consumable::class);
$consumable = Consumable::findOrFail($id);
$consumable->fill($request->all());
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$consumable = $request->handleImages($consumable);
if ($consumable->save()) {
@@ -304,34 +308,42 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
}
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
if (! $user = User::find($request->input('assigned_to'))) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company users and make failures ambiguous.
if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
// Return error message
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($consumable->company_id !== $user->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Update the consumable data
$consumable->assigned_to = $request->input('assigned_to');
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
// Keep pivot writes and checkout log/event atomic to avoid partial checkout state.
DB::transaction(function () use ($consumable, $request, $user): void {
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
@@ -361,11 +373,11 @@ class ConsumablesController extends Controller
public function history(Request $request, Consumable $consumable): JsonResponse|array
{
$this->authorize('history', $consumable);
$history = $consumable->getHistory($request);
$total = $consumable->getHistory($request)->count();
$historyQuery = $consumable->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -9,6 +9,7 @@ use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreDepartmentRequest;
use App\Http\Transformers\DepartmentsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Department;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -50,23 +51,23 @@ class DepartmentsController extends Controller
}
if ($request->filled('name')) {
$departments->where('name', '=', $request->input('name'));
$departments->where('departments.name', '=', $request->input('name'));
}
if ($request->filled('company_id')) {
$departments->where('company_id', '=', $request->input('company_id'));
$departments->where('departments.company_id', '=', $request->input('company_id'));
}
if ($request->filled('manager_id')) {
$departments->where('manager_id', '=', $request->input('manager_id'));
$departments->where('departments.manager_id', '=', $request->input('manager_id'));
}
if ($request->filled('location_id')) {
$departments->where('location_id', '=', $request->input('location_id'));
$departments->where('departments.location_id', '=', $request->input('location_id'));
}
if ($request->filled('tag_color')) {
$departments->where('tag_color', '=', $request->input('departments.tag_color'));
$departments->where('departments.tag_color', '=', $request->input('tag_color'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -111,6 +112,7 @@ class DepartmentsController extends Controller
{
$department = new Department;
$department->fill($request->validated());
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$department = $request->handleImages($department);
$department->created_by = auth()->id();
@@ -155,6 +157,7 @@ class DepartmentsController extends Controller
$this->authorize('update', Department::class);
$department = Department::findOrFail($id);
$department->fill($request->all());
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$department = $request->handleImages($department);
if ($department->save()) {
@@ -8,9 +8,11 @@ use App\Http\Transformers\LicenseSeatsTransformer;
use App\Models\Asset;
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;
class LicenseSeatsController extends Controller
{
@@ -106,7 +108,8 @@ class LicenseSeatsController extends Controller
'prohibits:asset_id',
// must be a valid user or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected assigned_to is invalid.');
}
},
@@ -118,7 +121,8 @@ class LicenseSeatsController extends Controller
'prohibits:assigned_to',
// must be a valid asset or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected asset_id is invalid.');
}
},
@@ -139,6 +143,34 @@ class LicenseSeatsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
$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'));
}
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')));
}
}
$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'));
}
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')));
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
@@ -166,11 +198,11 @@ class LicenseSeatsController extends Controller
// 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 : User::find($licenseSeat->assigned_to);
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : Asset::find($licenseSeat->asset_id);
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
@@ -181,13 +213,22 @@ class LicenseSeatsController extends Controller
}
}
if ($licenseSeat->save()) {
// 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;
}
if ($assignmentTouched) {
if ($is_checkin) {
if (! $licenseSeat->license->reassignable) {
$licenseSeat->unreassignable_seat = true;
$licenseSeat->save();
if (! $licenseSeat->save()) {
return false;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
@@ -196,6 +237,10 @@ class LicenseSeatsController extends Controller
}
}
return true;
});
if ($updated) {
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
}
@@ -8,6 +8,7 @@ use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\License;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
@@ -27,7 +28,7 @@ class LicensesController extends Controller
{
$this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
$settings = Setting::getSettings();
if ($request->input('status') == 'inactive') {
@@ -179,6 +180,7 @@ class LicensesController extends Controller
$this->authorize('create', License::class);
$license = new License;
$license->fill($request->all());
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
if ($license->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.create.success')));
@@ -219,6 +221,7 @@ class LicensesController extends Controller
$license = License::findOrFail($id);
$license->fill($request->all());
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
if ($license->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.update.success')));
@@ -244,7 +247,7 @@ class LicensesController extends Controller
if ($license->assigned_seats_count == 0) {
// Delete the license and the associated license seats
DB::table('license_seats')
->where('id', $license->id)
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$licenseSeats = $license->licenseseats();
@@ -282,11 +285,11 @@ class LicensesController extends Controller
public function history(Request $request, License $license): JsonResponse|array
{
$this->authorize('history', $license);
$history = $license->getHistory($request);
$total = $license->getHistory($request)->count();
$historyQuery = $license->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -462,11 +462,11 @@ class LocationsController extends Controller
public function history(Request $request, Location $location): JsonResponse|array
{
$this->authorize('history', $location);
$history = $location->getHistory($request);
$total = $location->getHistory($request)->count();
$historyQuery = $location->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -257,14 +257,12 @@ class MaintenancesController extends Controller
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
{
$this->authorize('view', Asset::class);
$asset = $maintenance->asset;
$this->authorize('history', $asset);
$history = $maintenance->getHistory($request);
$total = $maintenance->getHistory($request)->count();
$this->authorize('history', $maintenance);
$historyQuery = $maintenance->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -6,8 +6,18 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Maintenance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ReportsController extends Controller
@@ -125,4 +135,141 @@ class ReportsController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
/**
* Returns time-series data for the reports overview charts.
*
* Accepts ?days=N (preset, default 30) OR ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD.
* Also returns the immediately preceding period of equal length for comparison lines.
*/
public function activityChart(Request $request): JsonResponse
{
$this->authorize('reports.view');
$allowedDays = [7, 14, 30, 60, 90, 180, 365];
if ($request->filled('start_date') && $request->filled('end_date')) {
$curStart = Carbon::parse($request->input('start_date'))->startOfDay();
$curEnd = Carbon::parse($request->input('end_date'))->endOfDay();
if ($curEnd->lt($curStart)) {
[$curStart, $curEnd] = [$curEnd, $curStart];
}
$days = max(1, (int) $curStart->diffInDays($curEnd) + 1);
} else {
$days = in_array((int) $request->input('days'), $allowedDays) ? (int) $request->input('days') : 30;
$curEnd = Carbon::today()->endOfDay();
$curStart = Carbon::today()->subDays($days - 1)->startOfDay();
}
$prevEnd = $curStart->copy()->subSecond()->endOfDay();
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
$buildDates = function (Carbon $start, Carbon $end): array {
$dates = [];
for ($d = $start->copy(); $d->lte($end); $d->addDay()) {
$dates[] = $d->toDateString();
}
return $dates;
};
$curDates = $buildDates($curStart, $curEnd);
$prevDates = $buildDates($prevStart, $prevEnd);
$pluckAction = function (string $actionType, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// withTrashed() ensures records deleted after creation still appear in their creation-period counts.
$pluckCreated = function (string $modelClass, Carbon $start, Carbon $end): array {
return $modelClass::withTrashed()
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Maintenance has no company_id column and no CompanyableTrait, so scope through
// its asset relationship — whereHas('asset') applies Asset's FMCS global scope.
$pluckMaintenances = function (Carbon $start, Carbon $end): array {
return Maintenance::withTrashed()
->whereHas('asset')
->whereBetween('maintenances.created_at', [$start, $end])
->selectRaw('DATE(maintenances.created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Filters by both action_type and item_type for per-category checkout/checkin counts.
$pluckActionByType = function (string $actionType, string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$pluckDeletedUsers = function (Carbon $start, Carbon $end): array {
return User::withTrashed()
->whereNotNull('deleted_at')
->whereBetween('deleted_at', [$start, $end])
->selectRaw('DATE(deleted_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Catches both 'checkin' and 'checkin from' action types used across different item types.
$pluckCheckinsByType = function (string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::whereIn('action_type', ['checkin', 'checkin from'])
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$fill = fn (array $raw, array $dates) => array_map(fn ($d) => (int) ($raw[$d] ?? 0), $dates);
$datasets = [];
foreach ([
'new_users' => fn ($s, $e) => $pluckCreated(User::class, $s, $e),
'deleted_users' => fn ($s, $e) => $pluckDeletedUsers($s, $e),
'asset_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Asset::class, $s, $e),
'asset_checkins' => fn ($s, $e) => $pluckCheckinsByType(Asset::class, $s, $e),
'new_assets' => fn ($s, $e) => $pluckCreated(Asset::class, $s, $e),
'new_maintenances' => fn ($s, $e) => $pluckMaintenances($s, $e),
'new_audits' => fn ($s, $e) => $pluckAction('audit', $s, $e),
'component_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Component::class, $s, $e),
'component_checkins' => fn ($s, $e) => $pluckCheckinsByType(Component::class, $s, $e),
'new_components' => fn ($s, $e) => $pluckCreated(Component::class, $s, $e),
'consumable_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Consumable::class, $s, $e),
'consumable_checkins' => fn ($s, $e) => $pluckCheckinsByType(Consumable::class, $s, $e),
'new_consumables' => fn ($s, $e) => $pluckCreated(Consumable::class, $s, $e),
'license_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', LicenseSeat::class, $s, $e),
'license_checkins' => fn ($s, $e) => $pluckCheckinsByType(LicenseSeat::class, $s, $e),
'new_licenses' => fn ($s, $e) => $pluckCreated(License::class, $s, $e),
'accessory_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Accessory::class, $s, $e),
'accessory_checkins' => fn ($s, $e) => $pluckCheckinsByType(Accessory::class, $s, $e),
'new_accessories' => fn ($s, $e) => $pluckCreated(Accessory::class, $s, $e),
] as $key => $query) {
$datasets[$key] = $fill($query($curStart, $curEnd), $curDates);
$datasets['prev_'.$key] = $fill($query($prevStart, $prevEnd), $prevDates);
}
return response()->json(array_merge([
'labels' => array_map(fn ($d) => Carbon::parse($d)->format('M j'), $curDates),
'prev_label' => $prevStart->format('M j').' '.$prevEnd->format('M j'),
], $datasets));
}
}
@@ -162,6 +162,13 @@ class SettingsController extends Controller
public function ajaxTestEmail(): JsonResponse
{
if (! config('app.lock_passwords')) {
if (config('mail.reply_to.address') == '') {
Log::debug('MAIL_REPLYTO_ADDR not set in env. Skipping mail test.');
return response()->json(['message' => trans('admin/settings/general.mail_test_no_email')], 403);
}
try {
Notification::send(Setting::first(), new MailTest);
Log::debug('Attempting to sending to '.config('mail.reply_to.address'));
@@ -286,6 +293,11 @@ class SettingsController extends Controller
*/
public function downloadBackup($file): JsonResponse|BinaryFileResponse
{
$file = $this->sanitizeBackupFilename($file);
if ($file === null) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_not_found')), 404);
}
$path = storage_path('app/backups');
@@ -329,4 +341,21 @@ class SettingsController extends Controller
}
}
private function sanitizeBackupFilename(mixed $filename): ?string
{
$filename = trim((string) $filename);
if ($filename === '' || str_contains($filename, "\0")) {
return null;
}
$sanitized = basename($filename);
if (($sanitized === '') || ($sanitized === '.') || ($sanitized === '..')) {
return null;
}
return ($sanitized === $filename) ? $sanitized : null;
}
}
+22 -16
View File
@@ -808,21 +808,27 @@ class UsersController extends Controller
try {
$user = User::find($request->input('id'));
$this->authorize('update', $user);
$user->two_factor_secret = null;
$user->two_factor_enrolled = 0;
$user->saveQuietly();
// Log the reset
$logaction = new Actionlog;
$logaction->target_type = User::class;
$logaction->target_id = $user->id;
$logaction->item_type = User::class;
$logaction->item_id = $user->id;
$logaction->created_at = date('Y-m-d H:i:s');
$logaction->created_by = auth()->id();
$logaction->logaction('2FA reset');
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
$user->two_factor_secret = null;
$user->two_factor_enrolled = 0;
$user->saveQuietly();
// Log the reset
$logaction = new Actionlog;
$logaction->target_type = User::class;
$logaction->target_id = $user->id;
$logaction->item_type = User::class;
$logaction->item_id = $user->id;
$logaction->created_at = date('Y-m-d H:i:s');
$logaction->created_by = auth()->id();
$logaction->logaction('2FA reset');
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
}
return response()->json(['message' => trans('general.unauthorized')], 500);
} catch (\Exception $e) {
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_error')], 500);
}
@@ -939,11 +945,11 @@ class UsersController extends Controller
public function history(Request $request, User $user): JsonResponse|array
{
$this->authorize('history', $user);
$history = $user->getHistory($request);
$total = $user->getHistory($request)->count();
$historyQuery = $user->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = $history->skip($offset)->take($limit)->get();
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
@@ -10,6 +10,7 @@ use App\Http\Traits\MigratesLegacyAssetLocations;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\LicenseSeat;
use App\Models\Statuslabel;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
@@ -56,9 +57,16 @@ class AssetCheckinController extends Controller
default => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.user')]),
};
$deployableStatusIds = array_map('intval', array_keys(Helper::deployableStatusLabelList()));
$selectedStatusId = old('status_id');
$showRequestableToggle = is_numeric($selectedStatusId)
&& in_array((int) $selectedStatusId, $deployableStatusIds, true);
return view('hardware/checkin', compact('asset', 'target_option'))
->with('item', $asset)
->with('statusLabel_list', Helper::statusLabelList())
->with('deployable_status_ids', $deployableStatusIds)
->with('show_requestable_toggle', $showRequestableToggle)
->with('backto', $backto)
->with('table_name', 'Assets');
}
@@ -107,6 +115,19 @@ class AssetCheckinController extends Controller
$asset->status_id = e($request->input('status_id'));
}
$selectedStatusId = $request->filled('status_id')
? (int) $request->input('status_id')
: (int) $asset->status_id;
$isDeployableStatus = Statuslabel::query()
->whereKey($selectedStatusId)
->where('deployable', 1)
->exists();
if ($request->boolean('set_requestable') && $isDeployableStatus) {
$asset->requestable = true;
}
// Add any custom fields that should be included in the checkout
$asset->customFieldsForCheckinCheckout('display_checkin');
@@ -154,6 +175,10 @@ class AssetCheckinController extends Controller
$asset->customFieldsForCheckinCheckout('display_checkin');
if ($asset->save()) {
// Update the location of any child assets
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
@@ -164,4 +189,34 @@ class AssetCheckinController extends Controller
// Redirect to the asset management page with error
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.checkin.error').$asset->getErrors());
}
/**
* This would only be used if the target is actually hard-deleted
* and literally does not exist in the database anymore. This will null out the assigned_to
* and assigned_type fields, but will not trigger any events or do any of the other things that a
* normal checkin would do, since the target itself is now invalid.
*/
public function forceCheckin(Asset $asset)
{
$this->authorize('checkin', $asset);
if (! $asset->hasOrphanedAssignment()) {
return redirect()->route('hardware.show', $asset->id)
->with('error', trans('admin/hardware/message.checkin.force_checkin_not_orphaned'));
}
$asset->assigned_to = null;
$asset->assigned_type = null;
if ($asset->save()) {
$asset->logForceCheckin();
return redirect()->route('hardware.show', $asset->id)
->with('success', trans('admin/hardware/message.checkin.force_checkin_orphaned_success'));
}
return redirect()->route('hardware.show', $asset->id)
->with('error', trans('admin/hardware/message.checkin.force_checkin_error'));
}
}
@@ -8,7 +8,9 @@ use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
@@ -101,6 +103,10 @@ class AssetCheckoutController extends Controller
$asset->status_id = $request->input('status_id');
}
if ($request->boolean('set_not_requestable')) {
$asset->requestable = false;
}
if (! empty($asset->licenseseats->all())) {
if (request('checkout_to_type') == 'user') {
foreach ($asset->licenseseats as $seat) {
@@ -122,9 +128,43 @@ class AssetCheckoutController extends Controller
}
}
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'), null, $request->boolean('sign_in_place'))) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person. The signature is
// attributed to the target user, not the admin.
if ($request->boolean('sign_in_place') && $target instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->where('assigned_to_id', $target->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($asset);
$acceptance->assignedTo()->associate($target);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $asset->id,
'sign_in_place_resource_type' => 'Assets',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/hardware/message.checkout.success'));
}
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'))) {
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('success', trans('admin/hardware/message.checkout.success'));
}
@@ -66,7 +66,8 @@ class AssetsController extends Controller
public function index(Request $request): View
{
$this->authorize('index', Asset::class);
$company = Company::find($request->input('company_id'));
$companyId = $request->input('company_id');
$company = is_scalar($companyId) ? Company::find($companyId) : null;
return view('hardware/index')->with('company', $company);
}
@@ -360,8 +361,23 @@ class AssetsController extends Controller
'url' => route('qr_code/hardware', $asset),
];
$total_maintenance_cost = $asset->maintenances?->sum('cost');
$total_asset_cost = ($asset->assignedAssets()?->AssetsForShow()) ? $asset->assignedAssets()?->AssetsForShow()?->sum('purchase_cost') : 0;
$total_license_cost = ($asset->licenses) ? $asset->licenses->sum('purchase_cost') : 0;
$total_accessory_cost = ($asset->accessories) ? $asset->accessories()->sum('purchase_cost') : 0;
$total_component_cost = ($asset->components) ? $asset->components->sum('calculated_purchase_cost') : 0;
$total_cost_for_asset = $asset->purchase_cost + $total_maintenance_cost + $total_asset_cost + $total_license_cost + $total_accessory_cost + $total_component_cost;
return view('hardware/view', compact('asset', 'qr_code', 'settings'))
->with('use_currency', $use_currency)->with('audit_log', $audit_log);
->with('total_maintenance_cost', $total_maintenance_cost)
->with('total_asset_cost', $total_asset_cost)
->with('total_license_cost', $total_license_cost)
->with('total_accessory_cost', $total_accessory_cost)
->with('total_component_cost', $total_component_cost)
->with('total_cost_for_asset', $total_cost_for_asset)
->with('use_currency', $use_currency)
->with('audit_log', $audit_log);
}
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
@@ -716,6 +716,10 @@ class BulkAssetsController extends Controller
$asset->status_id = $request->input('status_id');
}
if ($request->boolean('set_not_requestable')) {
$asset->requestable = false;
}
$checkout_success = $asset->checkOut($target, $admin, $checkout_at, $expected_checkin, e($request->input('note')), $asset->name, null);
// TODO - I think this logic is duplicated in the checkOut method?
+10 -8
View File
@@ -106,15 +106,21 @@ class LoginController extends Controller
if ($saml->isEnabled() && ! empty($samlData)) {
try {
$user = $saml->samlLogin($samlData);
$notValidAfter = new \Carbon\Carbon(@$samlData['assertionNotOnOrAfter']);
if (\Carbon::now()->greaterThanOrEqualTo($notValidAfter)) {
abort(400, 'Expired SAML Assertion');
}
if (SamlNonce::where('nonce', @$samlData['nonce'])->count() > 0) {
abort(400, 'Assertion has already been used');
try {
SamlNonce::create([
'nonce' => $samlData['nonce'],
'not_valid_after' => $notValidAfter,
]);
} catch (\Exception $e) {
\Log::error($e);
abort(400, 'Assertion has already been used.');
}
Log::debug('okay, fine, this is a new nonce then. Good for you.');
if (! is_null($user)) {
Auth::login($user);
} else {
@@ -128,10 +134,6 @@ class LoginController extends Controller
$user->last_login = \Carbon::now();
$user->saveQuietly();
}
$s = new SamlNonce;
$s->nonce = @$samlData['nonce'];
$s->not_valid_after = $notValidAfter;
$s->save();
} catch (\Exception $e) {
Log::debug('There was an error authenticating the SAML user: '.$e->getMessage());
@@ -433,7 +435,7 @@ class LoginController extends Controller
$user->saveQuietly();
$request->session()->put('2fa_authed', $user->id);
return redirect()->route('home')->with('success', trans('auth/message.signin.success'));
return redirect()->intended()->with('success', trans('auth/message.signin.success'));
}
return redirect()->route('two-factor')->with('error', trans('auth/message.two_factor.invalid_code'));
+2 -2
View File
@@ -74,8 +74,7 @@ class SamlController extends Controller
public function login(Request $request)
{
$auth = $this->saml->getAuth();
$ssoUrl = $auth->login(null, [], false, false, false, false);
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
return redirect()->away($ssoUrl);
}
@@ -96,6 +95,7 @@ class SamlController extends Controller
$saml = $this->saml;
$auth = $saml->getAuth();
$saml_exception = false;
session()->put('url.intended', $request->post('RelayState'));
try {
$auth->processResponse();
} catch (\Exception $e) {
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Consumables;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -116,12 +117,46 @@ class ConsumableCheckoutController extends Controller
$request->input('note'),
[],
$consumable->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => 'user']);
$request->request->add(['assigned_user' => $user->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested, redirect to the acceptance/signature page
// so the user can sign in person. The signature is attributed to the target user.
if ($request->boolean('sign_in_place')) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Consumable::class)
->where('checkoutable_id', $consumable->id)
->where('assigned_to_id', $user->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($consumable);
$acceptance->assignedTo()->associate($user);
$acceptance->qty = $quantity;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $consumable->id,
'sign_in_place_resource_type' => 'Consumables',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/consumables/message.checkout.success'));
}
// Redirect to the new consumable page
return Helper::getRedirectOption($request, $consumable->id, 'Consumables')
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Licenses;
use App\Http\Controllers\Controller;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class BulkLicensesController extends Controller
{
public function destroy(Request $request)
{
$this->authorize('delete', License::class);
$errors = [];
$success_count = 0;
foreach ($request->ids as $id) {
$license = License::find($id);
if (is_null($license)) {
$errors[] = trans('admin/licenses/message.does_not_exist');
continue;
}
if (! Gate::allows('delete', $license)) {
$errors[] = trans('general.insufficient_permissions');
continue;
}
if ($license->assigned_seats_count > 0) {
$errors[] = trans('admin/licenses/message.delete.bulk_checkout_warning', ['license_name' => $license->name]);
continue;
}
// Since assigned_seats_count == 0, all seats already have assigned_to and asset_id as null,
// so this update is effectively a no-op. It mirrors the single destroy() and is kept as a
// safety net. Bypassing Eloquent events here is intentional and safe — there is nothing
// assigned to trigger events on. Prior checkout/checkin history is preserved in action_log
// (keyed by LicenseSeat item_type/item_id) and remains accessible even after soft-delete.
DB::table('license_seats')
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$license->licenseseats()->delete();
$license->delete();
$success_count++;
}
if (count($errors) > 0) {
if ($success_count > 0) {
return redirect()->route('licenses.index')
->with('success', trans_choice('admin/licenses/message.delete.partial_success', $success_count, ['count' => $success_count]))
->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('success', trans('admin/licenses/message.delete.bulk_success'));
}
}
@@ -7,6 +7,7 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\LicenseCheckoutRequest;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
@@ -101,17 +102,53 @@ class LicenseCheckoutController extends Controller
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']);
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'), 'checkout_to_type' => 'user']);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'user',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
}
if ($checkoutTarget) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && $checkoutTarget instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->where('checkoutable_id', $licenseSeat->id)
->where('assigned_to_id', $checkoutTarget->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($licenseSeat);
$acceptance->assignedTo()->associate($checkoutTarget);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $license->id,
'sign_in_place_resource_type' => 'Licenses',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/licenses/message.checkout.success'));
}
return Helper::getRedirectOption($request, $license->id, 'Licenses')
->with('success', trans('admin/licenses/message.checkout.success'));
}
@@ -150,7 +187,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = $target->assigned_to;
}
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
@@ -167,7 +204,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = request('assigned_to');
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
@@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -388,6 +389,8 @@ class LicensesController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($licenses as $license) {
// Add a new row with data
$values = [
@@ -419,7 +422,14 @@ class LicensesController extends Controller
$license->created_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
@@ -3,12 +3,15 @@
namespace App\Http\Controllers;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\UploadFileRequest;
use App\Models\Asset;
use App\Models\Maintenance;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
/**
* This controller handles all actions related to Asset Maintenance for
@@ -72,6 +75,7 @@ class MaintenancesController extends Controller
public function store(ImageUploadRequest $request): RedirectResponse
{
$this->authorize('update', Asset::class);
$this->validateUploadedFiles($request);
$assets = Asset::whereIn('id', $request->input('selected_assets'))->get();
@@ -102,12 +106,14 @@ class MaintenancesController extends Controller
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$maintenance = $request->handleImages($maintenance);
$request->handleImages($maintenance);
// Was the asset maintenance created?
if (! $maintenance->save()) {
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
}
$this->storeUploadedFiles($request, $maintenance);
}
return redirect()->route('maintenances.index')
@@ -156,6 +162,7 @@ class MaintenancesController extends Controller
{
$this->authorize('update', Asset::class);
$this->authorize('update', $maintenance->asset);
$this->validateUploadedFiles($request);
$maintenance->supplier_id = $request->input('supplier_id');
$maintenance->is_warranty = $request->input('is_warranty', 0);
@@ -184,9 +191,11 @@ class MaintenancesController extends Controller
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$maintenance = $request->handleImages($maintenance);
$request->handleImages($maintenance);
if ($maintenance->save()) {
$this->storeUploadedFiles($request, $maintenance);
return redirect()->route('maintenances.index')
->with('success', trans('admin/maintenances/message.edit.success'));
}
@@ -194,6 +203,56 @@ class MaintenancesController extends Controller
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
}
/**
* Stores any generic file uploads submitted from the maintenance form.
*/
private function storeUploadedFiles(ImageUploadRequest $request, Maintenance $maintenance): void
{
if (! $request->hasFile('file')) {
return;
}
$objectType = 'maintenances';
$storagePath = self::$map_storage_path[$objectType];
if (! Storage::exists($storagePath)) {
Storage::makeDirectory($storagePath, 775);
}
$uploadFileRequest = app(UploadFileRequest::class);
foreach ((array) $request->file('file') as $file) {
if (! $file) {
continue;
}
$fileName = $uploadFileRequest->handleFile(
$storagePath,
self::$map_file_prefix[$objectType].'-'.$maintenance->id,
$file
);
$maintenance->logUpload($fileName, $request->input('file_notes'));
}
}
/**
* Validate generic file uploads with the shared UploadFileRequest rules.
*/
private function validateUploadedFiles(ImageUploadRequest $request): void
{
if (! $request->hasFile('file')) {
return;
}
$uploadFileRequest = app(UploadFileRequest::class);
Validator::make(
array_merge($request->all(), ['file' => $request->file('file')]),
$uploadFileRequest->rules()
)->validate();
}
/**
* Delete an asset maintenance
*
+13 -7
View File
@@ -211,14 +211,19 @@ class ProfileController extends Controller
*/
public function printInventory(): View
{
$show_users = User::where('id', auth()->user()->id)->get();
$userId = auth()->id();
return view('users/print')
->with('assets', auth()->user()->assets())
->with('licenses', auth()->user()->licenses()->get())
->with('accessories', auth()->user()->accessories()->get())
->with('consumables', auth()->user()->consumables()->get())
->with('users', $show_users)
$show_user = User::withInventoryRelations($userId)->first();
$indirectItemsCount =
$show_user->assets->flatMap->assignedAssets->count()
+ $show_user->assets->flatMap->components->count()
+ $show_user->assets->flatMap->licenses->count()
+ $show_user->assets->flatMap->assignedAccessories->count();
return view('users.print')
->with('users', [$show_user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
@@ -251,6 +256,7 @@ class ProfileController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
$logentry = Actionlog::where('filename', $filename)->first();
+56 -5
View File
@@ -32,6 +32,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use League\Csv\EscapeFormula;
@@ -55,6 +56,31 @@ class ReportsController extends Controller
parent::__construct();
}
public function index(): View
{
$this->authorize('reports.view');
$settings = Setting::getSettings();
$audit_alert_count = Asset::DueOrOverdueForAudit($settings)->count();
$checkin_alert_count = Asset::DueOrOverdueForCheckin($settings)->count();
// CheckoutAcceptance has no company_id column; scope through the checkoutable
// relationship so each type's CompanyableTrait global scope is applied.
$pending_acceptance_count = CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class, LicenseSeat::class, Accessory::class, Component::class, Consumable::class])
->count();
$licenses_low_count = License::withCount(['freeSeats as free_seats_count'])
->get()
->filter(fn ($l) => $l->free_seats_count <= 0)
->count();
return view('reports/index', compact(
'audit_alert_count',
'checkin_alert_count',
'pending_acceptance_count',
'licenses_low_count',
));
}
/**
* Returns a view that displays the accessories report.
*
@@ -251,6 +277,7 @@ class ReportsController extends Controller
$response = new StreamedResponse(function () {
Log::debug('Starting streamed response');
Log::debug('CSV escaping is set to: '.config('app.escape_formulas'));
// Open output stream
$handle = fopen('php://output', 'w');
@@ -286,6 +313,8 @@ class ReportsController extends Controller
Log::debug('Walking results: '.$executionTime);
$count = 0;
$formatter = new EscapeFormula('`');
foreach ($actionlogs as $actionlog) {
$count++;
$target_name = '';
@@ -316,7 +345,15 @@ class ReportsController extends Controller
$actionlog->action_source,
$actionlog->log_meta,
];
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
@@ -797,6 +834,14 @@ class ReportsController extends Controller
$assets->onlyTrashed();
}
if ($request->input('assignment_status') === 'assigned') {
$assets->whereNotNull('assets.assigned_to');
}
if ($request->input('assignment_status') === 'unassigned') {
$assets->whereNull('assets.assigned_to');
}
$assets->orderBy('assets.id', 'ASC')->chunk(500, function ($assets) use ($handle, $customfields, $request) {
$executionTime = microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'];
@@ -843,7 +888,7 @@ class ReportsController extends Controller
}
if ($request->filled('purchase_date')) {
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
}
if ($request->filled('purchase_cost')) {
@@ -851,7 +896,7 @@ class ReportsController extends Controller
}
if ($request->filled('eol')) {
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
}
if ($request->filled('warranty')) {
@@ -917,7 +962,7 @@ class ReportsController extends Controller
if ($request->filled('user_company')) {
if ($asset->checkedOutToUser()) {
$row[] = ($asset->assignedto->company) ? $asset->assignedto->company->display_name : '';
$row[] = ($asset->assignedto?->company) ? $asset->assignedto?->company?->display_name : '';
} else {
$row[] = ''; // Empty string if unassigned
}
@@ -1070,7 +1115,13 @@ class ReportsController extends Controller
foreach ($customfields as $customfield) {
$column_name = $customfield->db_column_name();
if ($request->filled($customfield->db_column_name())) {
$row[] = $asset->$column_name;
$value = $asset->$column_name;
if (($customfield->field_encrypted == '1') && Gate::allows('assets.view.encrypted_custom_fields')) {
$value = Helper::gracefulDecrypt($customfield, $value);
}
$row[] = $value;
}
}
+156 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Helpers\StorageHelper;
use App\Http\Requests\ImageUploadRequest;
@@ -11,6 +12,7 @@ use App\Http\Requests\StoreLdapSettings;
use App\Http\Requests\StoreLocalizationSettings;
use App\Http\Requests\StoreNotificationSettings;
use App\Http\Requests\StoreSecuritySettings;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CustomField;
use App\Models\Group;
@@ -870,6 +872,11 @@ class SettingsController extends Controller
public function downloadFile($filename = null): RedirectResponse|BinaryFileResponse
{
$path = 'app/backups';
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
if (Storage::exists($path.'/'.$filename)) {
@@ -895,6 +902,12 @@ class SettingsController extends Controller
*/
public function deleteFile($filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (config('app.allow_backup_delete') == 'true') {
if (! config('app.lock_passwords')) {
@@ -969,6 +982,11 @@ class SettingsController extends Controller
*/
public function postRestore(Request $request, $filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
$path = 'app/backups';
@@ -1118,7 +1136,86 @@ class SettingsController extends Controller
*/
public function api(): View
{
return view('settings.api');
$personalAccessTokenCount = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_clients.personal_access_client', true)
->count();
return view('settings.api', [
'personalAccessTokenCount' => $personalAccessTokenCount,
]);
}
/**
* Revoke a personal access token from the admin OAuth settings page.
*/
public function revokePersonalAccessToken(string $token): RedirectResponse
{
$tokenRow = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_access_tokens.id', $token)
->where('oauth_clients.personal_access_client', true)
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
->first();
if ($tokenRow === null) {
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('error', trans('admin/settings/message.oauth.token_not_found'));
}
DB::table('oauth_access_tokens')
->where('id', $tokenRow->id)
->update(['revoked' => true]);
$logaction = new Actionlog;
$logaction->item_type = User::class;
$logaction->item_id = $tokenRow->user_id;
$logaction->target_type = User::class;
$logaction->target_id = $tokenRow->user_id;
$logaction->created_by = auth()->id();
// $logaction->note = 'Token ID: ' . $tokenRow->id;
$logaction->logaction(ActionType::TokenRevoked);
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('success', trans('admin/settings/message.oauth.token_revoked'));
}
/**
* Unrevoke a personal access token from the admin OAuth settings page.
*/
public function unrevokePersonalAccessToken(string $token): RedirectResponse
{
$tokenRow = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_access_tokens.id', $token)
->where('oauth_clients.personal_access_client', true)
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
->first();
if ($tokenRow === null) {
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('error', trans('admin/settings/message.oauth.token_not_found'));
}
DB::table('oauth_access_tokens')
->where('id', $tokenRow->id)
->update(['revoked' => false]);
$logaction = new Actionlog;
$logaction->item_type = User::class;
$logaction->item_id = $tokenRow->user_id;
$logaction->target_type = User::class;
$logaction->target_id = $tokenRow->user_id;
$logaction->created_by = auth()->id();
// $logaction->note = 'Token ID: ' . $tokenRow->id;
$logaction->logaction(ActionType::TokenUnrevoked);
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('success', trans('admin/settings/message.oauth.token_unrevoked'));
}
/**
@@ -1155,4 +1252,62 @@ class SettingsController extends Controller
{
return view('settings.logins');
}
/**
* Revoke an OAuth client from the admin OAuth settings page.
*/
public function revokeOAuthClient(string $client): RedirectResponse
{
$oauthClient = DB::table('oauth_clients')
->where('id', $client)
->first();
if ($oauthClient === null) {
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('error', trans('admin/settings/message.oauth.client_not_found'));
}
DB::table('oauth_clients')
->where('id', $client)
->update(['revoked' => true]);
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('success', trans('admin/settings/message.oauth.client_revoked'));
}
/**
* Unrevoke an OAuth client from the admin OAuth settings page.
*/
public function unrevokeOAuthClient(string $client): RedirectResponse
{
$oauthClient = DB::table('oauth_clients')
->where('id', $client)
->first();
if ($oauthClient === null) {
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('error', trans('admin/settings/message.oauth.client_not_found'));
}
DB::table('oauth_clients')
->where('id', $client)
->update(['revoked' => false]);
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('success', trans('admin/settings/message.oauth.client_unrevoked'));
}
private function hasInvalidBackupFilename(string $filename): bool
{
if ($filename === '' || $filename === '.' || $filename === '..') {
return true;
}
// Reject path separators in case a crafted value survives route decoding.
return str_contains($filename, '/') || str_contains($filename, '\\');
}
}
@@ -17,13 +17,17 @@ class StorageProxyController extends Controller
*/
public function show(string $path): Response|StreamedResponse
{
if ($this->hasPathTraversalSegments($path)) {
abort(404);
}
$disk = Storage::disk('public');
// The S3 adapter includes the disk's root prefix in generated URLs,
// but Flysystem also prepends it internally on every operation.
// Strip it here to avoid double-prefixing.
$root = trim(config('filesystems.disks.public.root', ''), '/');
if ($root !== '' && str_starts_with($path, $root . '/')) {
if ($root !== '' && str_starts_with($path, $root.'/')) {
$path = substr($path, strlen($root) + 1);
}
@@ -33,12 +37,12 @@ class StorageProxyController extends Controller
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
$lastModified = $disk->lastModified($path);
$etag = md5($path . $lastModified);
$etag = md5($path.$lastModified);
$size = $disk->size($path);
if ($this->isNotModified($etag, $lastModified)) {
return response('', 304)
->header('ETag', '"' . $etag . '"')
->header('ETag', '"'.$etag.'"')
->header('Cache-Control', 'public, max-age=86400');
}
@@ -51,8 +55,8 @@ class StorageProxyController extends Controller
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $size,
'ETag' => '"' . $etag . '"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
'ETag' => '"'.$etag.'"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified).' GMT',
'Cache-Control' => 'public, max-age=86400',
]);
}
@@ -60,7 +64,7 @@ class StorageProxyController extends Controller
private function isNotModified(string $etag, int $lastModified): bool
{
$requestEtag = request()->header('If-None-Match');
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
if ($requestEtag && $requestEtag === '"'.$etag.'"') {
return true;
}
@@ -71,4 +75,16 @@ class StorageProxyController extends Controller
return false;
}
private function hasPathTraversalSegments(string $path): bool
{
$normalizedPath = str_replace('\\', '/', $path);
return str_contains($normalizedPath, "\0")
|| str_starts_with($normalizedPath, '/')
|| str_contains($normalizedPath, '../')
|| str_contains($normalizedPath, '/..')
|| str_ends_with($normalizedPath, '/..')
|| $normalizedPath === '..';
}
}
@@ -362,7 +362,9 @@ class BulkUsersController extends Controller
$logAction->target_id = $item->assigned_to;
$logAction->target_type = User::class;
$logAction->created_by = auth()->id();
$logAction->note = 'Bulk checkin items';
$logAction->action_date = now();
$logAction->created_at = now();
$logAction->note = 'Bulk checkin items on user bulk edit/delete';
$logAction->logaction('checkin from');
}
}
@@ -376,7 +378,9 @@ class BulkUsersController extends Controller
$logAction->target_id = $accessoryUserRow->assigned_to;
$logAction->target_type = User::class;
$logAction->created_by = auth()->id();
$logAction->note = 'Bulk checkin items';
$logAction->created_at = now();
$logAction->action_date = now();
$logAction->note = 'Bulk checkin accessory on user bulk edit/delete';
$logAction->logaction('checkin from');
}
}
+62 -21
View File
@@ -9,8 +9,10 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteUserRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\SaveUserRequest;
use App\Mail\UnacceptedAssetReminderMail;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Group;
use App\Models\Setting;
@@ -22,7 +24,9 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -436,6 +440,7 @@ class UsersController extends Controller
'accessories',
'licenses',
'userloc',
'groups',
])
->withTrashed()
->find($user->id);
@@ -446,6 +451,7 @@ class UsersController extends Controller
return view('users/view', [
'user' => $user,
'settings' => Setting::getSettings(),
'effectivePermissionsBySection' => $user->getEffectivePermissionsBySection(),
]);
}
@@ -567,6 +573,8 @@ class UsersController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($users as $user) {
$user_groups = '';
@@ -609,7 +617,14 @@ class UsersController extends Controller
$user->created_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
@@ -634,32 +649,16 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'accessories.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
'consumables.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'licenses.category',
])
->withTrashed()
->first();
$user = User::withInventoryRelations($id)->first();
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
if ($user) {
$this->authorize('view', $user);
return view('users.print')
->with('users', [$user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
@@ -700,6 +699,48 @@ class UsersController extends Controller
}
/**
* Resend pending acceptance reminder email for a specific user.
*/
public function resendAcceptanceReminder(User $user): RedirectResponse
{
$this->authorize('view', $user);
if (empty($user->email)) {
return redirect()->back()->with('error', trans('admin/users/message.user_has_no_email'));
}
if ($user->activated == '0') {
return redirect()->back()->with('error', trans('admin/users/message.not_activated'));
}
$pendingItems = $user->getAssignedItemsWithPendingAcceptance();
if ($pendingItems->isEmpty()) {
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
}
$firstAcceptance = CheckoutAcceptance::query()
->forUser($user)
->pending()
->with('assignedTo')
->first();
if (! $firstAcceptance) {
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
}
$mailable = new UnacceptedAssetReminderMail($firstAcceptance, $pendingItems->count());
if (! empty($user->locale)) {
$mailable->locale($user->locale);
}
Mail::to($user->email)->send($mailable);
return redirect()->back()->with('success', trans_choice('admin/users/message.success.acceptance_reminder_sent', $pendingItems->count(), ['count' => $pendingItems->count()]));
}
/**
* Send individual password reset email
*
+2
View File
@@ -11,6 +11,7 @@ use App\Http\Middleware\CheckLocale;
use App\Http\Middleware\CheckPermissions;
use App\Http\Middleware\CheckUserIsActivated;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\LogAuthedUserHeader;
use App\Http\Middleware\NoSessionStore;
use App\Http\Middleware\PreventBackHistory;
use App\Http\Middleware\RedirectIfAuthenticated;
@@ -82,6 +83,7 @@ class Kernel extends HttpKernel
'api' => [
'auth:api',
CheckLocale::class,
LogAuthedUserHeader::class,
SubstituteBindings::class,
],
@@ -46,6 +46,7 @@ class CheckForTwoFactor
return $next($request);
}
redirect()->setIntendedUrl(url()->full()); // save the 'current' URL so we can send the user back to it?
// Otherwise make sure they're enrolled and show them the 2FA code screen
if ((auth()->user()->two_factor_secret != '') && (auth()->user()->two_factor_enrolled == '1')) {
return redirect()->route('two-factor')->with('info', trans('auth/message.two_factor.enter_two_factor_code'));
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class LogAuthedUserHeader
{
/**
* Handle an incoming request.
*
* @param Request $request
* @return mixed
*/
public function handle($request, Closure $next)
{
$response = $next($request);
if ((config('app.authorized_user_header') === true) && ($request->bearerToken() != '')) {
$response->headers->set('X-API-User-ID', auth()?->id());
$response->headers->set('X-API-Token-Name', $request->user()?->token()?->name);
$response->headers->set('X-API-Token-ID', $request->user()?->token()?->id);
}
return $response;
}
}
+3 -1
View File
@@ -25,7 +25,9 @@ class AssetCheckinRequest extends Request
{
$settings = Setting::getSettings();
$rules = [];
$rules = [
'set_requestable' => 'nullable|boolean',
];
if ($settings->require_checkinout_notes) {
$rules['note'] = 'string|required';
@@ -39,6 +39,8 @@ class AssetCheckoutRequest extends Request
'nullable',
'date',
],
'requestable' => 'nullable|boolean',
'set_not_requestable' => 'nullable|boolean',
];
if ($settings->require_checkinout_notes) {
@@ -29,6 +29,7 @@ class CustomAssetReportRequest extends Request
public function rules()
{
return [
'assignment_status' => 'nullable|in:all,assigned,unassigned',
'purchase_start' => 'date|date_format:Y-m-d|nullable',
'purchase_end' => 'date|date_format:Y-m-d|nullable',
'purchase_cost_end' => 'numeric|nullable|gte:purchase_cost_start',
+1
View File
@@ -57,6 +57,7 @@ class ImageUploadRequest extends Request
* had it once to allow encoded image uploads.
*/
return [
'avatar' => 'auto',
'image' => 'auto',
'image_source' => 'auto',
];
+9 -5
View File
@@ -192,8 +192,8 @@ class AssetsTransformer
'pivot_id' => $component->pivot->id,
'name' => e($component->name),
'qty' => $component->pivot->assigned_qty,
'price_cost' => $component->purchase_cost,
'purchase_total' => $component->purchase_cost * $component->pivot->assigned_qty,
'purchase_cost' => $component->purchase_cost,
'purchase_total' => $component->calculated_purchase_cost,
'checkout_date' => Helper::getFormattedDateObject($component->pivot->created_at, 'datetime'),
];
@@ -403,9 +403,10 @@ class AssetsTransformer
$array[] = [
'assigned_pivot_id' => $component_checkout->id,
'name' => [
'id' => $component_checkout->component->id,
'name' => e($component_checkout->component->display_name),
'id' => $component_checkout->component?->id,
'name' => e($component_checkout->component?->display_name),
'type' => 'component',
'deleted_at' => $component_checkout->component?->deleted_at,
],
'assigned_qty' => $component_checkout->assigned_qty,
'note' => ($component_checkout->note) ? e($component_checkout->note) : null,
@@ -414,7 +415,10 @@ class AssetsTransformer
'id' => (int) $component_checkout->adminuser->id,
'name' => e($component_checkout->adminuser->display_name),
] : null,
'available_actions' => ['checkin' => Gate::allows('checkin', Component::class)],
'available_actions' => [
'checkin' => (($component_checkout->component?->deleted_at == '') && Gate::allows('checkin', Component::class)),
'view' => (($component_checkout->component?->deleted_at == '') && Gate::allows('view', Component::class)),
],
];
}
@@ -51,7 +51,7 @@ class CategoriesTransformer
'name' => e($category->name),
'image' => ($category->image) ? Storage::disk('public')->url('categories/'.e($category->image)) : null,
'category_type' => Helper::categoryTypeList($category->category_type),
'has_eula' => ($category->getEula() ? true : false),
'has_eula' => ($category->eula_text) ? true : false,
'use_default_eula' => ($category->use_default_eula == '1' ? true : false),
'eula' => ($category->getEula()),
'checkin_email' => ($category->checkin_email == '1'),
@@ -75,7 +75,10 @@ class LicensesTransformer
'checkin' => Gate::allows('checkin', License::class),
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => (Gate::allows('delete', License::class) && ($license->free_seats_count == $license->seats)) ? true : false,
'delete' => $license->isDeletable(),
'bulk_selectable' => [
'delete' => $license->isDeletable(),
],
];
$array += $permissions_array;
@@ -6,6 +6,7 @@ use App\Helpers\Helper;
use App\Helpers\StorageHelper;
use App\Models\Actionlog;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
@@ -24,6 +25,17 @@ class UploadedFilesTransformer
public function transformFile(Actionlog $file)
{
$snipeModel = $file->item_type;
$item = null;
if (is_string($snipeModel) && class_exists($snipeModel)) {
$itemQuery = $snipeModel::query();
if (in_array(SoftDeletes::class, class_uses_recursive($snipeModel), true)) {
$itemQuery->withTrashed();
}
$item = $itemQuery->find($file->item_id);
}
$array = [
'id' => (int) $file->id,
@@ -49,7 +61,7 @@ class UploadedFilesTransformer
];
$permissions_array['available_actions'] = [
'delete' => (Gate::allows('update', $snipeModel) && ($file->deleted_at == '')),
'delete' => (Gate::allows('update', $item ?? $snipeModel) && ($file->deleted_at == '')),
];
$array += $permissions_array;
+37 -11
View File
@@ -34,12 +34,14 @@ use App\Notifications\CheckoutLicenseSeatNotification;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notification as BaseNotification;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Osama\LaravelTeamsNotification\TeamsNotification;
class CheckoutableListener
{
private array $skipNotificationsFor = [
@@ -80,6 +82,11 @@ class CheckoutableListener
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if ($this->shouldSkipInitialAcceptanceEmail($event, $acceptance)) {
$shouldSendEmailToUser = false;
$shouldSendEmailToAlertAddress = false;
}
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
return;
}
@@ -120,12 +127,12 @@ class CheckoutableListener
if ($shouldSendWebhookNotification) {
try {
if ($this->newMicrosoftTeamsWebhookEnabled()) {
$message = $this->getCheckoutNotification($event)->toMicrosoftTeams();
$message = $this->getCheckoutNotification($event, $acceptance, true)->toMicrosoftTeams();
$notification = new TeamsNotification(Setting::getSettings()->webhook_endpoint);
$notification->success()->sendMessage($message[0], $message[1]); // Send the message to Microsoft Teams
} else {
Notification::route($this->webhookSelected(), Setting::getSettings()->webhook_endpoint)
->notify($this->getCheckoutNotification($event, $acceptance));
->notify($this->getCheckoutNotification($event, $acceptance, true));
}
} catch (ClientException $e) {
$status = $e->getResponse()->getStatusCode();
@@ -227,12 +234,12 @@ class CheckoutableListener
// Send Webhook notification
try {
if ($this->newMicrosoftTeamsWebhookEnabled()) {
$message = $this->getCheckinNotification($event)->toMicrosoftTeams();
$message = $this->getCheckinNotification($event, true)->toMicrosoftTeams();
$notification = new TeamsNotification(Setting::getSettings()->webhook_endpoint);
$notification->success()->sendMessage($message[0], $message[1]); // Send the message to Microsoft Teams
} else {
Notification::route($this->webhookSelected(), Setting::getSettings()->webhook_endpoint)
->notify($this->getCheckinNotification($event));
->notify($this->getCheckinNotification($event, true));
}
} catch (ClientException $e) {
$status = $e->getResponse()->getStatusCode();
@@ -306,12 +313,12 @@ class CheckoutableListener
* @param CheckoutableCheckedIn $event
* @return Notification
*/
private function getCheckinNotification($event)
private function getCheckinNotification($event, bool $refreshCheckoutable = false): BaseNotification
{
$notificationClass = null;
$checkoutable = $this->getCheckoutableForNotification($event->checkoutable, $refreshCheckoutable);
switch (get_class($event->checkoutable)) {
switch (get_class($checkoutable)) {
case Accessory::class:
$notificationClass = CheckinAccessoryNotification::class;
break;
@@ -328,7 +335,7 @@ class CheckoutableListener
Log::debug('Notification class: '.$notificationClass);
return new $notificationClass($event->checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
return new $notificationClass($checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
}
/**
@@ -338,11 +345,12 @@ class CheckoutableListener
* @param CheckoutAcceptance|null $acceptance
* @return Notification
*/
private function getCheckoutNotification($event, $acceptance = null)
private function getCheckoutNotification($event, $acceptance = null, bool $refreshCheckoutable = false): BaseNotification
{
$notificationClass = null;
$checkoutable = $this->getCheckoutableForNotification($event->checkoutable, $refreshCheckoutable);
switch (get_class($event->checkoutable)) {
switch (get_class($checkoutable)) {
case Accessory::class:
$notificationClass = CheckoutAccessoryNotification::class;
break;
@@ -360,7 +368,16 @@ class CheckoutableListener
break;
}
return new $notificationClass($event->checkoutable, $event->checkedOutTo, $event->checkedOutBy, $acceptance, $event->note);
return new $notificationClass($checkoutable, $event->checkedOutTo, $event->checkedOutBy, $acceptance, $event->note);
}
private function getCheckoutableForNotification(Model $checkoutable, bool $shouldRefresh): Model
{
if (! $shouldRefresh) {
return $checkoutable;
}
return $checkoutable->fresh() ?? $checkoutable;
}
private function getCheckoutMailType($event, $acceptance)
@@ -480,6 +497,15 @@ class CheckoutableListener
return false;
}
private function shouldSkipInitialAcceptanceEmail(CheckoutableCheckedOut $event, ?CheckoutAcceptance $acceptance): bool
{
if (! $event->signInPlace) {
return false;
}
return ($acceptance instanceof CheckoutAcceptance) || ! empty($event->checkoutable->getEula());
}
private function shouldSendEmailToAlertAddress($acceptance = null): bool
{
if (Context::get('action') === 'bulk_asset_checkout') {
@@ -135,14 +135,18 @@ class CheckoutablesCheckedOutInBulkListener
return false;
}
private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event): ?Model
private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event): ?User
{
$target = $event->target;
if ($target instanceof Asset) {
$target->load('assignedTo');
return $target->assignedto;
if ($target->assigned instanceof User) {
return $target->assigned;
}
return null;
}
if ($target instanceof Location) {
@@ -0,0 +1,40 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
/**
* Livewire component for the admin-facing User API Tokens (Personal Access Tokens) table.
* Displays all personal access tokens across all users, used on the Settings > OAuth page.
*/
class AdminPersonalAccessTokens extends Component
{
public function render()
{
$tokens = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->leftJoin('users', 'oauth_access_tokens.user_id', '=', 'users.id')
->where('oauth_clients.personal_access_client', true)
->select([
'oauth_access_tokens.id',
'oauth_access_tokens.name',
'oauth_access_tokens.revoked',
'oauth_access_tokens.created_at',
'oauth_access_tokens.expires_at',
'oauth_access_tokens.user_id as token_user_id',
'oauth_clients.name as client_name',
'users.id as existing_user_id',
'users.username as username',
'users.display_name as display_name',
'users.deleted_at as user_deleted_at',
])
->orderByDesc('oauth_access_tokens.created_at')
->get();
return view('livewire.admin-personal-access-tokens', [
'tokens' => $tokens,
]);
}
}
+7
View File
@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Models\Setting;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -45,4 +46,10 @@ class CategoryEditForm extends Component
{
return (bool) $this->useDefaultEula;
}
#[Computed]
public function isGlobalSignatureRequired(): bool
{
return (string) Setting::getSettings()->require_accept_signature === '1';
}
}
+4
View File
@@ -639,6 +639,10 @@ class Importer extends Component
'color code',
trans('general.tag_color'),
],
'checkout_class' => [
'checkout type',
'checkout class',
],
];
$this->columnOptions[''] = $this->getColumns(''); // blank mode? I don't know what this is supposed to mean
+87 -12
View File
@@ -2,14 +2,16 @@
namespace App\Livewire;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\TokenRepository;
use Livewire\Component;
class OauthClients extends Component
{
public string $section = 'all';
public $name;
public $redirect;
@@ -22,11 +24,77 @@ class OauthClients extends Component
public $authorizationError;
public function mount(?string $section = null): void
{
if ($section !== null) {
$this->section = $section;
}
}
public function showOauthClients(): bool
{
return in_array($this->section, ['all', 'oauth-clients'], true);
}
public function showAuthorizedApplications(): bool
{
return in_array($this->section, ['all', 'authorized-applications'], true);
}
public function render()
{
$clients = collect();
if ($this->showOauthClients()) {
$clients = Client::query()
->orderByDesc('created_at')
->get();
if ($clients->isNotEmpty()) {
$tokenCountsByClientId = DB::table('oauth_access_tokens')
->whereIn('client_id', $clients->pluck('id')->all())
->selectRaw('client_id, COUNT(*) as token_count')
->groupBy('client_id')
->pluck('token_count', 'client_id');
$clients->each(function ($client) use ($tokenCountsByClientId): void {
$client->setAttribute('associated_token_count', (int) ($tokenCountsByClientId[$client->id] ?? 0));
});
}
}
$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',
])
->orderByDesc('token_summary.created_at')
->get();
}
return view('livewire.oauth-clients', [
'clients' => app(ClientRepository::class)->activeForUser(auth()->id()),
'authorized_tokens' => app(TokenRepository::class)->forUser(auth()->id())->where('revoked', false),
'clients' => $clients,
'authorizedApplications' => $authorizedApplications,
]);
}
@@ -43,6 +111,7 @@ class OauthClients extends Component
$this->redirect,
);
session()->flash('success', trans('admin/settings/message.oauth.client_created'));
$this->dispatch('clientCreated');
}
@@ -50,22 +119,27 @@ class OauthClients extends Component
{
// test for safety
// ->delete must be of type Client - thus the model binding
if ($clientId->user_id == auth()->id()) {
if ((auth()->user()?->isSuperUser()) || ($clientId->user_id == auth()->id())) {
app(ClientRepository::class)->delete($clientId);
session()->flash('success', trans('admin/settings/message.oauth.client_deleted'));
} else {
Log::warning('User '.auth()->id().' attempted to delete client '.$clientId->id.' which belongs to user '.$clientId->created_by);
$this->authorizationError = 'You are not authorized to delete this client.';
$this->authorizationError = trans('admin/settings/message.oauth.client_delete_denied');
}
}
public function deleteToken($tokenId): void
public function deleteAuthorizedApplication(int $clientId): void
{
$token = app(TokenRepository::class)->find($tokenId);
if ($token->created_by == auth()->id()) {
app(TokenRepository::class)->revokeAccessToken($tokenId);
$revokedTokenCount = DB::table('oauth_access_tokens')
->where('client_id', $clientId)
->where('revoked', false)
->update(['revoked' => true]);
if ($revokedTokenCount > 0) {
session()->flash('success', trans('admin/settings/message.oauth.token_deleted'));
} else {
Log::warning('User '.auth()->id().' attempted to delete token '.$tokenId.' which belongs to user '.$token->created_by);
$this->authorizationError = 'You are not authorized to delete this token.';
Log::warning('User '.auth()->id().' attempted to revoke authorized application client '.$clientId.' without matching active tokens.');
$this->authorizationError = trans('admin/settings/message.oauth.token_delete_denied');
}
}
@@ -91,9 +165,10 @@ class OauthClients extends Component
$client->name = $this->editName;
$client->redirect = $this->editRedirect;
$client->save();
session()->flash('success', trans('admin/settings/message.oauth.client_updated'));
} else {
Log::warning('User '.auth()->id().' attempted to edit client '.$editClientId->id.' which belongs to user '.$client->created_by);
$this->authorizationError = 'You are not authorized to edit this client.';
$this->authorizationError = trans('admin/settings/message.oauth.client_edit_denied');
}
$this->dispatch('clientUpdated');
+17
View File
@@ -58,10 +58,26 @@ class CheckinAssetMail extends BaseMailable
{
$this->item->load('status');
$fields = [];
$customFields = [];
// Check if the item has custom fields associated with it
if (($this->item->model) && ($this->item->model->fieldset)) {
$fields = $this->item->model->fieldset->fields;
foreach ($fields as $field) {
if (! $field->show_in_email || $field->field_encrypted == '1') {
continue;
}
$value = $this->item->{$field->db_column_name()};
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $field->name,
'value' => $value,
];
}
}
}
return new Content(
@@ -73,6 +89,7 @@ class CheckinAssetMail extends BaseMailable
'note' => $this->note,
'target' => $this->target,
'fields' => $fields,
'custom_fields' => $customFields,
'expected_checkin' => $this->expected_checkin,
],
);
+17
View File
@@ -75,6 +75,7 @@ class CheckoutAssetMail extends BaseMailable
$eula = method_exists($this->item, 'getEula') ? $this->item->getEula() : '';
$req_accept = $this->requiresAcceptance();
$fields = [];
$customFields = [];
$name = null;
if ($this->target instanceof User) {
@@ -88,6 +89,21 @@ class CheckoutAssetMail extends BaseMailable
// Check if the item has custom fields associated with it
if (($this->item->model) && ($this->item->model->fieldset)) {
$fields = $this->item->model->fieldset->fields;
foreach ($fields as $field) {
if (! $field->show_in_email || $field->field_encrypted == '1') {
continue;
}
$value = $this->item->{$field->db_column_name()};
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $field->name,
'value' => $value,
];
}
}
}
$accept_url = is_null($this->acceptance) ? null : route('account.accept.item', $this->acceptance);
@@ -101,6 +117,7 @@ class CheckoutAssetMail extends BaseMailable
'note' => $this->note,
'target' => $name,
'fields' => $fields,
'custom_fields' => $customFields,
'eula' => $eula,
'req_accept' => $req_accept,
'accept_url' => $accept_url,
+105 -18
View File
@@ -9,8 +9,11 @@ use App\Presenters\ActionlogPresenter;
use App\Presenters\Presentable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
@@ -52,6 +55,13 @@ class Actionlog extends SnipeModel
use Searchable;
/**
* Cache whether a model table has a company_id column.
*
* @var array<string, bool>
*/
protected static array $companyColumnCache = [];
/**
* The attributes that should be included when searching the model.
*
@@ -115,25 +125,81 @@ class Actionlog extends SnipeModel
public static function boot()
{
parent::boot();
static::creating(
function (self $actionlog) {
// If the admin is a superadmin, let's see if the target instead has a company.
if (auth()->user() && auth()->user()->isSuperUser()) {
if ($actionlog->target) {
$actionlog->company_id = $actionlog->target->company_id;
} elseif ($actionlog->item) {
$actionlog->company_id = $actionlog->item->company_id;
}
} elseif (auth()->user() && auth()->user()->company) {
$actionlog->company_id = auth()->user()->company_id;
}
if ($actionlog->action_date == '') {
$actionlog->action_date = Carbon::now();
}
static::creating(function (self $actionlog): void {
// Only resolve company_id if it was never explicitly set by the caller.
// Using array_key_exists on getRawOriginal() / getAttributes() lets us
// distinguish "was set to null intentionally" from "was never set at all".
if (! array_key_exists('company_id', $actionlog->getAttributes())) {
$actionlog->company_id = static::resolveCompanyIdFromAttributes(
$actionlog->target_type,
$actionlog->target_id,
$actionlog->item_type,
$actionlog->item_id,
);
}
);
if ($actionlog->action_date == '') {
$actionlog->action_date = Carbon::now();
}
});
}
/**
* Resolve the company_id for a new action log by querying the item model
* directly, bypassing all global scopes to avoid FMCS filtering issues.
*
* We intentionally prefer the item (asset, license, etc.) over the target
* (user, location) because FMCS visibility is based on who *owns* the item,
* not who it was checked out to. If the item has no company_id we fall back
* to the target so that logs on unowned items still get a company stamp where
* possible.
*
* This has to include an exception for the asset models table, since they are
* not company-constrained (on purpose.)
*/
protected static function resolveCompanyIdFromAttributes(
?string $targetType,
?int $targetId,
?string $itemType,
?int $itemId,
): ?int {
// Prefer the item (the thing being acted upon) for FMCS ownership.
$companyId = static::resolveCompanyIdFromModelClass($itemType, $itemId);
if ($companyId !== null) {
return $companyId;
}
// Fall back to target only when the item has no company_id.
return static::resolveCompanyIdFromModelClass($targetType, $targetId);
}
/**
* Resolve company_id from a model class and ID, but only if that model's
* table has a company_id column.
*/
protected static function resolveCompanyIdFromModelClass(?string $modelClass, ?int $id): ?int
{
if (! $modelClass || ! $id || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) {
return null;
}
/** @var Model $instance */
$instance = app($modelClass);
$table = $instance->getTable();
$hasCompanyColumn = static::$companyColumnCache[$table]
??= Schema::hasColumn($table, 'company_id');
if (! $hasCompanyColumn) {
return null;
}
return $modelClass::withoutGlobalScopes()
->whereKey($id)
->value('company_id');
}
/**
@@ -328,6 +394,27 @@ class Actionlog extends SnipeModel
return $this->morphTo('target')->withTrashed();
}
/**
* Eager load history relations used by the API transformer to avoid N+1 queries.
*/
public function scopeForApiHistory($query)
{
return $query->with([
'adminuser',
'location',
'item' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Asset::class => ['model'],
]);
},
'target' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Asset::class => ['model'],
]);
},
]);
}
/**
* Establishes the actionlog -> location relationship
*
+69 -7
View File
@@ -228,6 +228,8 @@ class Asset extends Depreciable
protected $searchableRelationAliases = [
'status_label' => 'status',
'assigned_to' => 'assignedTo',
'model_number' => 'model',
'rtd_location' => 'defaultLoc',
];
protected static function booted(): void
@@ -516,7 +518,7 @@ class Asset extends Depreciable
*
* @return bool
*/
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null)
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null, bool $signInPlace = false)
{
if (! $target) {
return false;
@@ -560,7 +562,7 @@ class Asset extends Depreciable
} else {
$checkedOutBy = auth()->user();
}
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues));
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues, 1, $signInPlace));
$this->increment('checkout_counter', 1);
@@ -761,9 +763,25 @@ class Asset extends Depreciable
*/
public function assignedAccessories()
{
return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to');
return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to')->with('accessory');
}
public function accessories()
{
return $this->hasManyThrough(
Accessory::class,
AccessoryCheckout::class,
'assigned_to',
'id',
'id',
'accessory_id'
)->where('assigned_type', self::class);
}
// {
// return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to')->withTrashed();
// }
/**
* Get the asset's location based on the assigned user
*
@@ -1248,12 +1266,44 @@ class Asset extends Depreciable
public function getComponentCost()
{
$cost = 0;
foreach ($this->components as $component) {
$cost += $component->pivot->assigned_qty * $component->purchase_cost;
return (float) $this->components->sum('calculated_purchase_cost');
}
/**
* Return EOL progress percentage (0-100), based on elapsed months since
* purchase date over the configured EOL window.
*/
public function eolProgressPercent(): float
{
if (! $this->purchase_date || ! $this->asset_eol_date) {
return 0.0;
}
return $cost;
return $this->calculateProgressPercent(
start: Carbon::parse($this->purchase_date),
end: Carbon::parse($this->asset_eol_date),
);
}
/**
* Return warranty progress percentage (0-100), based on elapsed months
* since purchase date over the warranty window.
*/
public function warrantyProgressPercent(): float
{
if (! $this->purchase_date || ! $this->warranty_expires) {
return 0.0;
}
return $this->calculateProgressPercent(
start: Carbon::parse($this->purchase_date),
end: $this->warranty_expires,
);
}
public function getAccessoryCost()
{
return (float) $this->accessories()->sum('purchase_cost');
}
/**
@@ -2118,4 +2168,16 @@ class Asset extends Depreciable
->join('depreciations', 'models.depreciation_id', '=', 'depreciations.id')->where('models.depreciation_id', '=', $search);
}
/**
* Determines if the asset has an orphaned assignment where the assigned target no longer exists.
* This occurs when:
* 1. assigned_to is set but assigned_type is missing/null
* 2. assigned_to and assigned_type are both set, but the relationship cannot be resolved (target was hard-deleted)
*/
public function hasOrphanedAssignment(): bool
{
return ($this->assigned_to && ! $this->assigned_type)
|| ($this->assigned_to && $this->assigned_type && ! $this->assignedTo);
}
}
+14
View File
@@ -243,13 +243,27 @@ class CheckoutAcceptance extends Model
if ($data['item_serial'] != null) {
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
}
if (!empty($data['custom_fields']) && is_iterable($data['custom_fields'])) {
foreach ($data['custom_fields'] as $customField) {
$label = $customField['label'] ?? null;
$value = $customField['value'] ?? null;
if (($label !== null) && ($value !== null) && ($value !== '')) {
$pdf->writeHTML(e((string) $label) . ': ' . e((string) $value), true, 0, true, 0, '');
}
}
}
if (($data['qty'] != null) && ($data['qty'] > 1)) {
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
$pdf->writeHTML(trans('general.assignee').': '.e($data['assigned_to']).($data['employee_num'] ? ' ('.$data['employee_num'].')' : ''), true, 0, true, 0, '');
if ($data['email'] != null) {
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
+22 -1
View File
@@ -8,6 +8,7 @@ use App\Models\Traits\Loggable;
use App\Models\Traits\Searchable;
use App\Presenters\ComponentPresenter;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
@@ -165,6 +166,26 @@ class Component extends SnipeModel
return $this->belongsToMany(Asset::class, 'components_assets')->withPivot('id', 'assigned_qty', 'created_at', 'created_by', 'note');
}
protected function calculatedPurchaseCost(): Attribute
{
return Attribute::make(
get: function ($value) {
$unitPurchaseCost = $this->getRawOriginal('purchase_cost');
$assignedQty = $this->pivot?->assigned_qty;
if ($unitPurchaseCost === null) {
return $assignedQty !== null ? 0.0 : null;
}
if ($assignedQty !== null) {
return (float) $unitPurchaseCost * (int) $assignedQty;
}
return (float) $unitPurchaseCost;
}
);
}
/**
* Establishes the component -> company relationship
*
@@ -232,7 +253,7 @@ class Component extends SnipeModel
*/
public function requireAcceptance()
{
return $this->category->require_acceptance;
return $this->category?->require_acceptance ?? false;
}
/**
+3 -3
View File
@@ -40,17 +40,17 @@ class ComponentAssignment extends Model
*/
public function component()
{
return $this->belongsTo(Component::class);
return $this->belongsTo(Component::class)->withTrashed();
}
public function components()
{
return $this->hasMany(Component::class, 'id', 'component_id');
return $this->hasMany(Component::class, 'id', 'component_id')->withTrashed();
}
public function assets()
{
return $this->hasMany(Asset::class, 'id', 'asset_id');
return $this->hasMany(Asset::class, 'id', 'asset_id')->withTrashed();
}
/**
+5 -1
View File
@@ -170,6 +170,10 @@ class Consumable extends SnipeModel
return 100;
}
if (($this->qty == '') || ($this->qty == 0)) {
return 0;
}
return ($this->qty - $this->consumables_users_count) / $this->qty * 100;
}
@@ -318,7 +322,7 @@ class Consumable extends SnipeModel
*/
public function requireAcceptance()
{
return $this->category->require_acceptance;
return $this->category?->require_acceptance ?? false;
}
/**
+35
View File
@@ -2,6 +2,8 @@
namespace App\Models;
use Carbon\Carbon;
class Depreciable extends SnipeModel
{
/**
@@ -187,6 +189,39 @@ class Depreciable extends SnipeModel
}
/**
* Return depreciation progress percentage (0-100), based on elapsed months
* since purchase date over the depreciation window.
*/
public function depreciationProgressPercent(): float
{
if (! $this->purchase_date || ! $this->depreciated_date()) {
return 0.0;
}
return $this->calculateProgressPercent(
start: Carbon::parse($this->purchase_date),
end: Carbon::instance($this->depreciated_date()),
);
}
/**
* Calculate elapsed/total month percentage and clamp to 0-100.
*/
protected function calculateProgressPercent(Carbon $start, Carbon $end): float
{
$totalMonths = (float) $start->diffInMonths($end);
if ($totalMonths <= 0) {
return 0.0;
}
$elapsedMonths = (float) $start->diffInMonths(Carbon::now());
$rawPercent = ($elapsedMonths / $totalMonths) * 100;
return (float) min(100, max(0, $rawPercent));
}
// it's necessary for unit tests
protected function getDateTime($time = null)
{
+6
View File
@@ -81,6 +81,12 @@ class Group extends SnipeModel
return $this->belongsToMany(User::class, 'users_groups');
}
/* this is just a shim for SCIM to work */
public function members()
{
return $this->users();
}
/**
* Decode JSON permissions into array
*
+15 -11
View File
@@ -68,17 +68,8 @@ class DefaultLabel extends RectangleSheet
$usableWidth = $this->pageWidth - $this->pageMarginLeft - $this->pageMarginRight;
$usableHeight = $this->pageHeight - $this->pageMarginTop - $this->pageMarginBottom;
$this->columns = ($usableWidth + $this->labelSpacingH) / ($this->labelWidth + $this->labelSpacingH);
$this->rows = ($usableHeight + $this->labelSpacingV) / ($this->labelHeight + $this->labelSpacingV);
// Make sure the columns and rows are never zero, since that scenario should never happen
if ($this->columns == 0) {
$this->columns = 1;
}
if ($this->rows == 0) {
$this->rows = 1;
}
$this->columns = $this->calculateGridCount($usableWidth, $this->labelWidth, $this->labelSpacingH);
$this->rows = $this->calculateGridCount($usableHeight, $this->labelHeight, $this->labelSpacingV);
}
@@ -299,4 +290,17 @@ class DefaultLabel extends RectangleSheet
return $labelHeight;
}
private function calculateGridCount(float $usableSize, float $labelSize, float $spacing): int
{
$denominator = $labelSize + $spacing;
if ($denominator <= 0.0) {
return 1;
}
$count = (int) floor(($usableSize + $spacing) / $denominator);
return max(1, $count);
}
}
+2 -2
View File
@@ -640,11 +640,11 @@ class License extends Depreciable
/**
* This is really dumb - needs to be refactored, since we have ~3 diff methods that do almost the same thing
*
* @author A. Gianotto <snipe@snipe.net>
* @return int
*
* @since [v2.0]
*
* @return Relation
* @author A. Gianotto <snipe@snipe.net>
*/
public function numRemaining()
{
+5
View File
@@ -180,6 +180,11 @@ class Location extends SnipeModel
);
}
public function countAllTheThings()
{
return $this->assets()->count() + $this->consumables()->count() + $this->components()->count() + $this->users()->count() + $this->assignedAccessories()->count() + $this->assignedAssets()->count() + $this->accessories()->count();
}
/**
* Establishes the asset -> rtd_location relationship
*
+2
View File
@@ -32,6 +32,8 @@ class Maintenance extends SnipeModel implements ICompanyableChild
protected $presenter = MaintenancesPresenter::class;
protected $with = ['asset', 'asset.company'];
protected $table = 'maintenances';
protected $rules = [
+9 -1
View File
@@ -13,4 +13,12 @@ class SCIMUser extends User
$attributes['password'] = $this->noPassword();
parent::__construct($attributes);
}
}
// Have to re-define this here because Eloquent will try to 'guess' a foreign key of s_c_i_m_user_id
// from SCIMUser
public function groups()
{
return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id');
}
}
+460 -211
View File
@@ -2,248 +2,497 @@
namespace App\Models;
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
use ArieTimmerman\Laravel\SCIMServer\Helper;
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Attribute;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Constant;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Eloquent;
use ArieTimmerman\Laravel\SCIMServer\Attribute\JSONCollection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
use Illuminate\Database\Eloquent\Model;
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
class SnipeSCIMConfig extends SCIMConfig
function a($name = null): Attribute
{
return new Attribute($name);
}
function complex($name = null): Complex
{
return new Complex($name);
}
function eloquent($name, $attribute = null): Attribute
{
return new Eloquent($name, $attribute);
}
class EloquentWithRemove extends Eloquent
{
public function remove($value, Model &$object, Path $path = null)
{
$object->{$this->attribute} = null;
}
}
class MappedTable extends Attribute
{
public function __construct(
private string $scim_attribute_name,
private string $relationship_name,
private string $relationship_class,
private string $relationship_id_field,
private string $relationship_field)
{
parent::__construct($this->scim_attribute_name);
}
protected function doRead(&$object, $attributes = [])
{
return $object->{$this->relationship_name}?->{$this->relationship_field};
}
public function add($value, Model &$object)
{
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
}
public function replace($value, Model &$object, $path = null, $removeIfNotSet = false)
{
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
}
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
}
}
class UpdatableComplex extends Complex
{
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
throw new \Exception("doWrite is not implemented yet for Operation: $operation " . ($subop ? "($subop)" : "") . "on attribute " . $this->getFullKey());
}
public function add($value, Model &$object)
{
$this->doWrite("add", null, $value, $object);
}
public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
$this->doWrite("replace", null, $value, $object, $path, $removeIfNotSet);
}
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
$this->doWrite("patch", $operation, $value, $object, $path, $removeIfNotSet);
}
public function remove($value, Model &$object, Path $path = null)
{
$this->doWrite("remove", null, null, $object, $path);
}
}
class SnipeSCIMConfig
{
public function __construct()
{
}
public function getConfigForResource($name)
{
$result = $this->getConfig();
return @$result[$name];
}
public function getGroupClass()
{
return Group::class;
}
const ENTERPRISE = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
const GROKABILITY = 'urn:ietf:params:scim:schemas:extension:grokability:2.0:User';
public function getUserConfig()
{
// Much of this is copied verbatim from the library, then adjusted for our needs
/*
more snipe-it attributes I'd like to check out (to map to 'enterprise' maybe?):
- website
- notes?
- remote???
- location_id ?
- company_id to "organization?"
*/
$user_prefix = 'urn:ietf:params:scim:schemas:core:2.0:User:';
$enterprise_prefix = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:';
return [
// Set to 'null' to make use of auth.providers.users.model (App\User::class)
'class' => SCIMUser::class,
'validations' => [
$user_prefix.'userName' => 'required',
$user_prefix.'displayName' => 'nullable|string',
$user_prefix.'name.givenName' => 'required',
$user_prefix.'name.familyName' => 'nullable|string',
$user_prefix.'externalId' => 'nullable|string',
$user_prefix.'emails' => 'nullable|array',
$user_prefix.'emails.*.value' => 'nullable|email',
$user_prefix.'active' => 'boolean',
$user_prefix.'phoneNumbers' => 'nullable|array',
$user_prefix.'phoneNumbers.*.value' => 'nullable|string',
$user_prefix.'addresses' => 'nullable|array',
$user_prefix.'addresses.*.streetAddress' => 'nullable|string',
$user_prefix.'addresses.*.locality' => 'nullable|string',
$user_prefix.'addresses.*.region' => 'nullable|string',
$user_prefix.'addresses.*.postalCode' => 'nullable|string',
$user_prefix.'addresses.*.country' => 'nullable|string',
$user_prefix.'title' => 'nullable|string',
$user_prefix.'preferredLanguage' => 'nullable|string',
// Enterprise validations:
$enterprise_prefix.'employeeNumber' => 'nullable|string',
$enterprise_prefix.'department' => 'nullable|string',
$enterprise_prefix.'manager' => 'nullable',
$enterprise_prefix.'manager.value' => 'nullable|string',
],
'singular' => 'User',
'schema' => [Schema::SCHEMA_USER],
// eager loading
'withRelations' => [],
'map_unmapped' => false,
// 'unmapped_namespace' => 'urn:ietf:params:scim:schemas:laravel:unmapped',
'description' => 'User Account',
// Map a SCIM attribute to an attribute of the object.
'mapping' => [
'id' => (new AttributeMapping)->setRead(
function (&$object) {
return (string) $object->id;
'map' => complex()->withSubAttributes(
new class ('schemas', [
"urn:ietf:params:scim:schemas:core:2.0:User",
self::ENTERPRISE,
self::GROKABILITY
]) extends Constant {
public function replace($value, &$object, $path = null)
{
// do nothing
$this->dirty = true;
}
},
(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;
}
)->disableWrite(),
'externalId' => AttributeMapping::eloquent('scim_externalid'), // FIXME - I have a PR that changes a lot of this.
'meta' => [
'created' => AttributeMapping::eloquent('created_at')->disableWrite(),
'lastModified' => AttributeMapping::eloquent('updated_at')->disableWrite(),
'location' => (new AttributeMapping)->setRead(
function ($object) {
return route(
'scim.resource',
[
'resourceType' => 'Users',
'resourceObject' => $object->id,
]
);
public function remove($value, &$object, $path = null)
{
// do nothing
}
}
),
new Meta('Users'),
(new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes(
eloquent('userName', 'username')->ensure('required'),
(new class ('active', 'activated') extends Eloquent {
protected function doRead(&$object, $attributes = [])
{
return (bool)$object->activated; // need this extension to force boolean-ness
}
)->disableWrite(),
}),
complex('name')->withSubAttributes(
eloquent('givenName', 'first_name')->ensure('required'),
eloquent('familyName', 'last_name'),
), // ->ensure('required'), It *is* a bit weird, but I would've thought 'name' is required since 'givenName' is required? But apparently not?
eloquent('displayName', 'display_name'), //yes, this is *not* under 'name' - that's the spec
//eloquent('password')->ensure('nullable')->setReturned('never'),
eloquent('externalId', 'scim_externalid'),
'resourceType' => AttributeMapping::constant('User'),
],
'schemas' => AttributeMapping::constant(
[
'urn:ietf:params:scim:schemas:core:2.0:User',
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
]
)->ignoreWrite(),
'urn:ietf:params:scim:schemas:core:2.0:User' => [
'userName' => AttributeMapping::eloquent('username'),
'name' => [
'formatted' => (new AttributeMapping)->ignoreWrite()->setRead(
function (&$object) {
return $object->getFullNameAttribute();
}
),
'familyName' => AttributeMapping::eloquent('last_name'),
'givenName' => AttributeMapping::eloquent('first_name'),
'middleName' => null,
'honorificPrefix' => null,
'honorificSuffix' => null,
],
'displayName' => AttributeMapping::eloquent('display_name'),
'nickName' => null,
'profileUrl' => null,
'title' => AttributeMapping::eloquent('jobtitle'),
'userType' => null,
'preferredLanguage' => AttributeMapping::eloquent('locale'), // Section 5.3.5 of [RFC7231]
'locale' => null, // see RFC5646
'timezone' => null, // see RFC6557
'active' => (new AttributeMapping)->setAdd(
function ($value, &$object) {
$object->activated = $value;
// Email chonk
(new class ('emails') extends UpdatableComplex {
protected function doRead(&$object, $attributes = [])
{
return collect([$object->email])->map(function ($email) {
return [
'value' => $email,
'type' => 'work', //TODO - is this how we always have done it?
'primary' => true
];
})->toArray();
}
)->setReplace(
function ($value, &$object) {
$object->activated = $value;
}
)->setRead(
// this works as specified.
function (&$object) {
return (bool) $object->activated;
}
),
'password' => AttributeMapping::eloquent('password')->disableRead(),
// Multi-Valued Attributes
'emails' => [[
'value' => AttributeMapping::eloquent('email'),
'display' => null,
'type' => AttributeMapping::constant('work')->ignoreWrite(),
'primary' => AttributeMapping::constant(true)->ignoreWrite(),
]],
'phoneNumbers' => [[
'value' => AttributeMapping::eloquent('phone'),
'display' => null,
'type' => AttributeMapping::constant('work')->ignoreWrite(),
'primary' => AttributeMapping::constant(true)->ignoreWrite(),
]],
'ims' => [[
'value' => null,
'display' => null,
'type' => null,
'primary' => null,
]], // Instant messaging addresses for the User
'photos' => [[
'value' => null,
'display' => null,
'type' => null,
'primary' => null,
]],
'addresses' => [[
'type' => AttributeMapping::constant('work')->ignoreWrite(),
'formatted' => AttributeMapping::constant('n/a')->ignoreWrite(), // TODO - is this right? This doesn't look right.
'streetAddress' => AttributeMapping::eloquent('address'),
'locality' => AttributeMapping::eloquent('city'),
'region' => AttributeMapping::eloquent('state'),
'postalCode' => AttributeMapping::eloquent('zip'),
'country' => AttributeMapping::eloquent('country'),
'primary' => AttributeMapping::constant(true)->ignoreWrite(), // this isn't in the example?
]],
'groups' => [[
'value' => null,
'$ref' => null,
'display' => null,
'type' => null,
]],
'entitlements' => null,
'roles' => null,
'x509Certificates' => null,
],
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => [
'employeeNumber' => AttributeMapping::eloquent('employee_num'),
'department' => (new AttributeMapping)->setAdd( // FIXME parent?
function ($value, &$object) {
$department = Department::where('name', $value)->first();
if ($department) {
$object->department_id = $department->id;
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
if ($value) {
try {
$object->email = $value[0]['value'];
} catch (\Throwable $e) {
\Log::debug($e);
throw new SCIMException("Unknown email object: '" . print_r($value, true) . "'", 422);
}
} else {
$object->email = null;
}
}
)->setReplace(
function ($value, &$object) {
$department = Department::where('name', $value)->first();
if ($department) {
$object->department_id = $department->id;
})->withSubAttributes(
eloquent('value', 'email')->ensure('email', 'nullable'), //Weird, this 'needs' nullable to work?
new Constant('type', 'work'),
(new Constant('primary', true))->ensure('boolean')
)->ensure('array')
->setMultiValued(true),
// phone chonk
(new class ('phoneNumbers') extends UpdatableComplex {
protected function doRead(&$object, $attributes = [])
{
$phones = [];
if ($object->phone) {
$phones[] = [
'value' => $object->phone,
'type' => 'work'
];
}
if ($object->mobile) {
$phones[] = [
'value' => $object->mobile,
'type' => 'mobile'
];
}
return $phones;
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
\Log::debug("Phones 'value' is: " . print_r($value, true));
try {
if ($operation == "patch") {
if ($path->getValuePathFilter() != null) {
if ((string) $path == 'phoneNumbers[type eq "mobile"].value') {
$object->mobile = $value; //I don't know why the value is the raw value, but it is?
return;
}
if ((string) $path == 'phoneNumbers[type eq "work"].value') {
$object->phone = $value; //similar, don't know why, but it is
return;
}
}
parent::patch($subop, $value, $object, $path, $removeIfNotSet);
return;
}
foreach ($value as $phone) {
switch ($phone['type']) {
case 'work':
$object->phone = $phone['value'];
break;
case 'mobile':
$object->mobile = $phone['value'];
break;
default:
throw new SCIMException("Unknown phone type '" . @$phone['type'] . "'", 400);
}
}
} catch (\Throwable $e) {
\Log::debug($e);
throw new SCIMException("Unknown phone object(s) '" . print_r($value, true) . "'", 422);
}
}
)->setRead(
function (&$object) {
return $object->department ? $object->department->name : null;
}
),
'manager' => [
// FIXME - manager writes are disabled. This kinda works but it leaks errors all over the place. Not cool.
// '$ref' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
// 'displayName' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
// NOTE: you could probably do a 'plain' Eloquent mapping here, but we don't for future-proofing
'value' => (new AttributeMapping)->setAdd(
function ($value, &$object) {
$manager = User::find($value);
if ($manager) {
$object->manager_id = $manager->id;
})->withSubAttributes( // TODO: I suspect these 'sub-attributes' aren't being checked at all
(new Constant('value', 'email'))->ensure('string'), // TODO - this is WRONG, but it works somehow? Probably because it's ignored
new Constant('type', 'other'), // TODO uh, *also* wrong? but, again, seems to be ignored
)->ensure('array')
->setMultiValued(true),
// addresses chonk
(new class ('addresses') extends UpdatableComplex {
static $addressmap = [
'streetAddress' => 'address',
'locality' => 'city',
'region' => 'state',
'postalCode' => 'zip',
'country' => 'country'
];
protected function doRead(&$object, $attributes = [])
{
$address = [];
foreach (self::$addressmap as $scim_field => $db_field) {
if ($object->{$db_field}) {
$address[$scim_field] = $object->{$db_field};
}
}
)->setReplace(
function ($value, &$object) {
$manager = User::find($value);
if ($manager) {
$object->manager_id = $manager->id;
if (count($address) > 0) {
$address['type'] = 'work';
$address['primary'] = true;
}
return $address;
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
// TODO - this is validated *just* for 'patch' operations, so this may not work in other write contexts
if ($path->getValuePathFilter() != null) {
\Log::debug("path for update $path");
// get the part of the $path that we actually care about - something like:
// addresses[type eq "work"]
$matches = null;
if (!preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string)$path, $matches)) {
throw new SCIMException("Unknown path type '$path'", 422);
}
$type = $matches[1];
if ($type != 'work') {
throw new SCIMException("Unknown object type '$type'", 422);
}
$attribute = array_key_exists(2, $matches) ? $matches[2] : null;
if (array_key_exists($attribute, self::$addressmap)) {
$object->{self::$addressmap[$attribute]} = $value;
return;
}
throw new SCIMException("Could not handle path for update $path", 422);
}
)->setRead(
function (&$object) {
return $object->manager_id;
}
})->withSubAttributes(
eloquent('streetAddress', 'address'),
eloquent('locality', 'city'),
eloquent('region', 'state'),
eloquent('postalCode', 'zip'),
eloquent('country', 'country'),
new Constant('type', 'other'),
(new Constant('primary', true))->ensure('boolean')
)->ensure('array')
->setMultiValued(true),
eloquent('title', 'jobtitle'),
eloquent('preferredLanguage', 'locale'),
(new Collection('groups'))->withSubAttributes(
eloquent('value', 'id'),
(new class ('$ref') extends Eloquent {
protected function doRead(&$object, $attributes = [])
{
return route(
'scim.resource',
[
'resourceType' => 'Group',
'resourceObject' => $object->id ?? "not-saved"
]
);
}
),
],
],
],
}),
eloquent('display', 'name')
),
(new JSONCollection('roles'))->withSubAttributes( // TODO - what is this?
eloquent('value')->ensure('required', 'min:3', 'alpha_dash:ascii'),
eloquent('display')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
eloquent('type')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
eloquent('primary')->ensure('boolean')->default(false)
)->ensure('nullable', 'array', 'max:20')
),
(new AttributeSchema(self::ENTERPRISE, false))->withSubAttributes(
eloquent('employeeNumber', 'employee_num')->ensure('nullable'),
new MappedTable('department', 'department', Department::class, 'department_id', 'name'),
(new class('manager') extends UpdatableComplex {
protected function doRead(&$object, $attributes = [])
{
if (!$object->manager) {
return null;
}
return [
'value' => $object->manager->id, //TODO - ID's aren't unique like they're supposed to be :/
'$ref' => route('scim.resource', ['resourceType' => 'User', 'resourceObject' => $object->manager->id]),
'displayName' => $object->manager->display_name,
];
}
public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false)
{
\Log::debug("What type of value is value? " . gettype($value));
$manager_id = null;
if (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)) {
// Here's the spec: https://datatracker.ietf.org/doc/html/rfc7643#section-4.3
// according to the spec it's _recommended_ to do:
// $ref - which should be the URI of the manager
// extract ID from URL, jam it in?
$url = $value['$ref'];
$users_prefix = route('scim.resources', ['resourceType' => 'User']) . '/';
if (string_starts_with($url, $users_prefix)) {
$manager_id = substr($url, strlen($users_prefix));
}
} elseif (array_key_exists('value', $value)) {
// this is _Snipe-IT_'s ID being passed as 'value' I believe?
// if you use the 'managerId' field in Okta, you get:
// [value] => 9999999
// that, at least, is the spec - but *what* ID is that?! It's supposed to be a Snipe-IT one!
$manager_id = $value['value'];
}
\Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: " . print_r($value, true));
if ($manager_id && User::find($manager_id)) {
$object->manager_id = $manager_id;
return;
}
throw new SCIMException("No manager given, or manager doesn't exist", 400);
}
}) // ->withSubAttributes() ... -> ensure() ?
),
(new AttributeSchema(self::GROKABILITY, false))->withSubAttributes(
new MappedTable('location', 'location', Location::class, 'location_id', 'name'),
new MappedTable('company', 'company', Company::class, 'company_id', 'name'),
)
),
];
}
public function getGroupConfig()
{
return [
'class' => $this->getGroupClass(),
'singular' => 'Group',
//eager loading
'withRelations' => [],
'description' => 'Group',
'map' => complex()->withSubAttributes(
new class ('schemas', [
"urn:ietf:params:scim:schemas:core:2.0:Group",
]) extends Constant {
public function replace($value, &$object, $path = null)
{
// do nothing
$this->dirty = true;
}
},
(new class ('id', null) extends Constant {
protected function doRead(&$object, $attributes = [])
{
return (string)$object->id;
}
public function remove($value, &$object, $path = null)
{
// do nothing
}
}
),
new EloquentWithRemove('externalId', 'scim_externalid'),
new Meta('Groups'),
(new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes(
eloquent('displayName', 'name')->ensure('required', 'min:3', function ($attribute, $value, $fail) {
// check if group does not exist or if it exists, it is the same group
$group = $this->getGroupClass()::where('name', $value)->first();
if ($group && (request()->route('resourceObject') == null || $group->id != request()->route('resourceObject')->id)) {
$fail('The name has already been taken.');
}
}),
(new MutableCollection('members'))->withSubAttributes(
eloquent('value', 'id')->ensure('required'),
(new class ('$ref') extends Eloquent {
protected function doRead(&$object, $attributes = [])
{
return route(
'scim.resource',
[
'resourceType' => 'Users',
'resourceObject' => $object->id ?? "not-saved"
]
);
}
}),
eloquent('display', 'name')
)->ensure('nullable', 'array')
)
),
];
}
public function getConfig()
{
return [
'Users' => $this->getUserConfig(),
'Groups' => $this->getGroupConfig(),
];
}
}
+67 -3
View File
@@ -4,6 +4,7 @@ namespace App\Models\Traits;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\ICompanyableChild;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Location;
@@ -107,7 +108,7 @@ trait Loggable
break;
}
return $history;
return $history->forApiHistory();
}
@@ -177,10 +178,11 @@ trait Loggable
$log->note = $note;
$log->action_date = $action_date;
$log->quantity = $quantity;
$log->company_id = $this->resolveLoggableCompanyId();
$changed = [];
$array_to_flip = array_keys($fields_array);
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin']);
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin', 'requestable']);
$originalValues = array_intersect_key($originalValues, array_flip($array_to_flip));
foreach ($originalValues as $key => $value) {
@@ -221,6 +223,37 @@ trait Loggable
return $log;
}
/**
* Resolve the company_id that should be stamped on an action log entry.
*
* LicenseSeat does not carry a company_id directly it belongs to a License,
* so we fetch the parent license's company_id in that case. All other models
* that use the Loggable trait have a company_id column directly.
*/
private function resolveLoggableCompanyId(): ?int
{
if (static::class === LicenseSeat::class) {
return $this->license?->company_id;
}
if (isset($this->company_id)) {
return $this->company_id;
}
// Companyable children (like Maintenance) inherit company visibility from parents.
if ($this instanceof ICompanyableChild) {
foreach ((array) $this->getCompanyableParents() as $parentRelation) {
$parent = $this->{$parentRelation} ?? null;
if (isset($parent?->company_id)) {
return $parent->company_id;
}
}
}
return null;
}
/**
* @author Daniel Meltzer <dmeltzer.devel@gmail.com>
*
@@ -267,6 +300,7 @@ trait Loggable
$log->location_id = null;
$log->note = $note;
$log->action_date = $action_date;
$log->company_id = $this->resolveLoggableCompanyId();
if (! $action_date) {
$log->action_date = date('Y-m-d H:i:s');
@@ -279,7 +313,7 @@ trait Loggable
$changed = [];
$array_to_flip = array_keys($fields_array);
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin']);
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin', 'requestable']);
$originalValues = array_intersect_key($originalValues, array_flip($array_to_flip));
@@ -303,6 +337,32 @@ trait Loggable
return $log;
}
/**
* Logs a force checkin action for orphaned assignments.
*
* Force checkin only records an explicit action log entry and intentionally
* skips checkin counters and changed-field metadata.
*
* @return Actionlog
*/
public function logForceCheckin($note = null)
{
$log = new Actionlog;
$log = $this->determineLogItemType($log);
$log->location_id = null;
$log->note = $note;
$log->action_date = date('Y-m-d H:i:s');
if (auth()->user()) {
$log->created_by = auth()->id();
}
$log->logaction('force checkin');
return $log;
}
/**
* @author A. Gianotto <snipe@snipe.net>
*
@@ -357,6 +417,8 @@ trait Loggable
$log->created_by = auth()->id();
$log->filename = $filename;
$log->action_date = date('Y-m-d H:i:s');
// Explicitly stamp company_id from the item being audited so FMCS scoping works correctly.
$log->company_id = $this->resolveLoggableCompanyId();
$log->logaction('audit');
$params = [
@@ -442,6 +504,7 @@ trait Loggable
$log->action_date = date('Y-m-d H:i:s');
$log->note = $note;
$log->created_by = $created_by;
$log->company_id = $this->resolveLoggableCompanyId();
$log->logaction('create');
$log->save();
@@ -468,6 +531,7 @@ trait Loggable
$log->created_by = auth()->id();
$log->note = $note;
$log->target_id = null;
$log->company_id = $this->resolveLoggableCompanyId();
$log->created_at = date('Y-m-d H:i:s');
$log->action_date = date('Y-m-d H:i:s');
$log->filename = $filename;
+551 -84
View File
@@ -56,9 +56,12 @@ trait Searchable
$preparedSearch = $this->prepareSearchInput((string) $search);
$terms = $preparedSearch['terms'];
$filters = $preparedSearch['filters'];
$filterOperator = $preparedSearch['filter_operator'];
if (! empty($filters)) {
return $this->applySearchFilters($query, $filters);
// Structured advanced-search filters are mutually exclusive with free-text terms.
// Once we detect structured payloads, we avoid the broad OR-based free-text path.
return $this->applySearchFilters($query, $filters, $filterOperator);
}
/**
@@ -101,15 +104,27 @@ trait Searchable
return [
'terms' => [],
'filters' => $parsedFilters,
'filter_operator' => $this->resolveStructuredFilterOperator(),
];
}
return [
'terms' => $this->prepeareSearchTerms($search),
'filters' => [],
'filter_operator' => 'and',
];
}
/**
* Resolve the structured advanced-search operator from the current request.
*/
private function resolveStructuredFilterOperator(): string
{
$operator = strtolower((string) request()->input('filter_operator', 'and'));
return $operator === 'or' ? 'or' : 'and';
}
/**
* Normalize a structured filter payload into scalar string filters.
*/
@@ -122,6 +137,7 @@ trait Searchable
$payload = $search;
if (str_starts_with($search, 'filter:')) {
// Some callers send filter payloads with an explicit "filter:" prefix.
$payload = substr($search, 7);
} elseif (! (str_starts_with($search, '{') && str_ends_with($search, '}'))) {
return null;
@@ -147,6 +163,7 @@ trait Searchable
$normalizedValue = trim((string) ($value ?? ''));
if ($normalizedValue === '') {
// Ignore empty fields so clearing an input does not create noisy no-op filters.
continue;
}
@@ -174,83 +191,305 @@ trait Searchable
*
* @param array<string, string> $filters
*/
private function applySearchFilters(Builder $query, array $filters): Builder
private function applySearchFilters(Builder $query, array $filters, string $filterOperator = 'and'): Builder
{
if ($filterOperator === 'or') {
$query->where(function (Builder $filterQuery) use ($filters) {
foreach ($filters as $filterKey => $filterValue) {
$this->applySingleSearchFilter($filterQuery, $filterKey, $filterValue, 'or');
}
});
return $query;
}
foreach ($filters as $filterKey => $filterValue) {
$this->applySingleSearchFilter($query, $filterKey, $filterValue);
}
return $query;
}
/**
* Parse a raw filter value for an optional negation, null-check, or exact-match prefix.
*
* Supported syntax:
* - "!flarb" operator = not_like, value = "flarb"
* - "not:flarb" operator = not_like, value = "flarb"
* - "is:null" operator = is_null, value = "" (reserved token)
* - "is:not_null" operator = is_not_null, value = "" (reserved token)
* - "is:flarb" operator = exact, value = "flarb" (exact equality)
* - "is_not:flarb" operator = exact_not, value = "flarb" (exact inequality)
*
* `is:null` and `is:not_null` are checked before the generic `is:` prefix so they always
* resolve to their dedicated null-check operators regardless of casing.
*
* The legacy `negate` boolean is preserved alongside `operator` so that
* existing callers that only check `negate` still work correctly.
*
* @return array{value: string, negate: bool, operator: string}
*/
private function parseFilterValue(string $raw): array
{
$lower = strtolower($raw);
if ($lower === 'is:null') {
// Reserved token: interpreted as null-check operator, not exact match string.
return ['value' => '', 'negate' => false, 'operator' => 'is_null'];
}
if ($lower === 'is:not_null') {
// Reserved token: interpreted as non-null check operator.
return ['value' => '', 'negate' => false, 'operator' => 'is_not_null'];
}
if (str_starts_with($lower, 'is:')) {
// Generic exact-match prefix. This is checked after reserved is:null/is:not_null tokens.
$exactValue = ltrim(substr($raw, 3));
return ['value' => $exactValue, 'negate' => false, 'operator' => 'exact'];
}
if (str_starts_with($lower, 'is_not:')) {
$exactNotValue = ltrim(substr($raw, 7));
return ['value' => $exactNotValue, 'negate' => true, 'operator' => 'exact_not'];
}
if (str_starts_with($raw, '!')) {
return ['value' => substr($raw, 1), 'negate' => true, 'operator' => 'not_like'];
}
if (str_starts_with($lower, 'not:')) {
return ['value' => substr($raw, 4), 'negate' => true, 'operator' => 'not_like'];
}
return ['value' => $raw, 'negate' => false, 'operator' => 'like'];
}
/**
* Apply a single structured filter using the provided boolean operator.
*
* Negation: if the filter value is prefixed with "!" or "not:", the filter
* uses NOT LIKE (for attributes/custom fields) or whereDoesntHave (for
* relations), effectively excluding records matching the value.
*
* For relation filters, negation uses NOT LIKE inside whereHas, meaning
* "has a related record where the column does NOT contain the value".
* Records with no related record (e.g. unassigned assets) are excluded;
* use a plain empty-string filter if you need to match NULLs.
*/
private function applySingleSearchFilter(Builder $query, string $filterKey, string $filterValue, string $boolean = 'and'): Builder
{
$parsed = $this->parseFilterValue($filterValue);
$value = $parsed['value'];
$negate = $parsed['negate'];
$operator = $parsed['operator'];
// IS NULL / IS NOT NULL are handled before value-based filtering,
// because there is no meaningful value to pass to LIKE for them.
if ($operator === 'is_null' || $operator === 'is_not_null') {
return $this->applyNullFilter($query, $filterKey, $operator === 'is_null', $boolean);
}
// Skip gracefully if stripping the prefix leaves an empty value.
if ($value === '') {
return $query;
}
$searchableAttributes = $this->getSearchableAttributes();
$searchableCounts = $this->getSearchableCounts();
$searchableRelations = $this->getSearchableRelations();
$table = $this->getTable();
$whereMethod = $boolean === 'or' ? 'orWhere' : 'where';
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
foreach ($filters as $filterKey => $filterValue) {
if (in_array($filterKey, $searchableAttributes, true)) {
$query->where($table.'.'.$filterKey, 'LIKE', '%'.$filterValue.'%');
continue;
if (in_array($filterKey, $searchableAttributes, true)) {
if ($isExactOperator) {
$query->{$whereMethod}($table.'.'.$filterKey, $exactComparisonOperator, $value);
} else {
$query->{$whereMethod}($table.'.'.$filterKey, $likeOperator, '%'.$value.'%');
}
if (in_array($filterKey, $searchableCounts, true)) {
$query = $this->applyCountAliasFilter($query, $filterKey, $filterValue);
return $query;
}
continue;
}
// Handle virtual columns — keys that are not real DB columns but map to a set
// of real columns searched via CONCAT (e.g. "name" → first_name + last_name on User).
$virtualColumns = $this->getSearchableVirtualColumns();
// Check if this is a custom field (only for Assets - for *now*).
// Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid
// collisions with standard attributes or relation filter keys.
if ($this instanceof Asset) {
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
if (array_key_exists($filterKey, $virtualColumns)) {
$qualifiedColumns = array_map(
fn ($col) => $table.'.'.$col,
$virtualColumns[$filterKey]
);
if ($dbColumn !== null) {
$query->where($table.'.'.$dbColumn, 'LIKE', '%'.$filterValue.'%');
if ($isExactOperator) {
// Exact match on the full CONCAT'd value, e.g. "John Smith" matches only
// users whose first_name + ' ' + last_name equals exactly "John Smith".
$concatSql = $this->buildMultipleColumnSearch($qualifiedColumns);
// buildMultipleColumnSearch intentionally returns a fragment ending in "LIKE ?";
// for exact matches we rewrite only the operator and keep the same SQL scaffold.
$concatSql = str_replace(' LIKE ?', $operator === 'exact_not' ? ' <> ?' : ' = ?', $concatSql);
$rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->{$rawMethod}($concatSql, [$value]);
} else {
$concatSql = $this->buildMultipleColumnSearch($qualifiedColumns);
continue;
}
}
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
if ($resolvedRelationKey === null) {
continue;
}
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
$query = $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $filterValue);
continue;
}
$relationColumns = (array) $searchableRelations[$resolvedRelationKey];
$query->whereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $filterValue) {
$relationTable = $this->getRelationTable($resolvedRelationKey);
$firstConditionAdded = false;
foreach ($relationColumns as $relationColumn) {
if (! $firstConditionAdded) {
$relationQuery->where($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
$firstConditionAdded = true;
continue;
}
$relationQuery->orWhere($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
if ($negate) {
$concatSql = str_replace(' LIKE ?', ' NOT LIKE ?', $concatSql);
}
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
$relationQuery->orWhereRaw(
$this->buildMultipleColumnSearch(
[
$rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->{$rawMethod}($concatSql, ['%'.$value.'%']);
}
return $query;
}
if (in_array($filterKey, $searchableCounts, true)) {
return $this->applyCountAliasFilter($query, $filterKey, $value, $boolean, $negate, $isExactOperator);
}
// Check if this is a custom field (only for Assets - for *now*).
// Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid
// collisions with standard attributes or relation filter keys.
if ($this instanceof Asset) {
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
if ($dbColumn !== null) {
if ($isExactOperator) {
$query->{$whereMethod}($table.'.'.$dbColumn, $exactComparisonOperator, $value);
} else {
$query->{$whereMethod}($table.'.'.$dbColumn, $likeOperator, '%'.$value.'%');
}
return $query;
}
}
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
if ($resolvedRelationKey === null) {
return $query;
}
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
return $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $value, $boolean, $negate, $operator);
}
$relationColumns = $this->getStructuredFilterRelationColumns(
filterKey: $filterKey,
resolvedRelationKey: $resolvedRelationKey,
searchableRelations: $searchableRelations,
);
// For negated relation filters (e.g. location: !dam), include rows with
// no related record as well as rows with related records that do not match.
// This aligns advanced-search behavior with user expectation for "not X".
if ($operator === 'not_like' || $operator === 'exact_not') {
$compoundMethod = $boolean === 'or' ? 'orWhere' : 'where';
$query->{$compoundMethod}(function (Builder $compoundQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
// Critical behavior: "not X" on relations should include records with no relation.
// Example: location=!dam should include users without a location.
$compoundQuery->doesntHave($resolvedRelationKey)
->orWhereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
$relationTable = $this->getRelationTable($resolvedRelationKey);
$firstConditionAdded = false;
$relationComparisonOperator = $operator === 'exact_not' ? '!=' : 'NOT LIKE';
$relationComparisonValue = $operator === 'exact_not' ? $value : '%'.$value.'%';
foreach ($relationColumns as $relationColumn) {
if (! $firstConditionAdded) {
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
$firstConditionAdded = true;
continue;
}
// For negation we AND the NOT LIKE conditions so all columns must not match.
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
}
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
$concatSql = $this->buildMultipleColumnSearch([
'users.first_name',
'users.last_name',
'users.display_name',
]
),
["%{$filterValue}%"]
);
}
]);
if ($operator === 'exact_not') {
$relationQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$value]);
} else {
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
}
}
});
});
return $query;
}
$relationMethod = $boolean === 'or' ? 'orWhereHas' : 'whereHas';
$query->{$relationMethod}($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $likeOperator, $operator) {
$relationTable = $this->getRelationTable($resolvedRelationKey);
$firstConditionAdded = false;
foreach ($relationColumns as $relationColumn) {
if (! $firstConditionAdded) {
if ($operator === 'exact') {
$relationQuery->where($relationTable.'.'.$relationColumn, '=', $value);
} elseif ($operator === 'exact_not') {
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
} else {
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
}
$firstConditionAdded = true;
continue;
}
if ($operator === 'exact') {
// For exact matches across multiple columns, OR them — any column matching
// the exact value is sufficient (e.g. name OR slug).
$relationQuery->orWhere($relationTable.'.'.$relationColumn, '=', $value);
} elseif ($operator === 'exact_not') {
// For exact exclusions we AND the conditions so no column can equal the value.
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
} elseif ($likeOperator === 'NOT LIKE') {
// For negation we AND the NOT LIKE conditions so all columns must not match.
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
} else {
// For normal LIKE we OR them so any column matching is sufficient.
$relationQuery->orWhere($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
}
}
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
$concatSql = $this->buildMultipleColumnSearch([
'users.first_name',
'users.last_name',
'users.display_name',
]);
if ($operator === 'exact') {
$concatSql = str_replace(' LIKE ?', ' = ?', $concatSql);
$relationQuery->orWhereRaw($concatSql, [$value]);
} elseif ($operator === 'exact_not') {
$concatSql = str_replace(' LIKE ?', ' <> ?', $concatSql);
$relationQuery->whereRaw($concatSql, [$value]);
} elseif ($likeOperator === 'NOT LIKE') {
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
} else {
$relationQuery->orWhereRaw($concatSql, ["%{$value}%"]);
}
}
});
return $query;
}
@@ -302,8 +541,13 @@ trait Searchable
/**
* Apply filters for assignees with type-specific searchable columns.
*
* When $negate is true, NOT LIKE is used inside whereHasMorph, so results
* are records that have an assignee whose columns do NOT contain $filterValue.
* (Records with no assignee are excluded; they do not satisfy "has an assignee
* where column NOT LIKE '%value%'".)
*/
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue): Builder
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue, string $boolean = 'and', bool $negate = false, string $operator = 'like'): Builder
{
$relationName = $this->resolveAssignedToRelationName();
@@ -311,10 +555,15 @@ trait Searchable
return $query;
}
return $query->whereHasMorph(
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
$relationMethod = $boolean === 'or' ? 'orWhereHasMorph' : 'whereHasMorph';
return $query->{$relationMethod}(
$relationName,
[User::class, Asset::class, Location::class],
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue) {
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue, $likeOperator, $negate, $operator, $isExactOperator, $exactComparisonOperator) {
$columns = $this->getAssigneeColumnsByType($assigneeType);
if (empty($columns)) {
@@ -326,20 +575,41 @@ trait Searchable
foreach ($columns as $column) {
if (! $firstConditionAdded) {
$assigneeQuery->where($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
if ($isExactOperator) {
$assigneeQuery->where($table.'.'.$column, $exactComparisonOperator, $filterValue);
} else {
$assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
}
$firstConditionAdded = true;
continue;
}
$assigneeQuery->orWhere($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
// For negation, AND the conditions (all columns must not match).
// For normal LIKE, OR them (any column matching is sufficient).
if ($operator === 'exact') {
$assigneeQuery->orWhere($table.'.'.$column, '=', $filterValue);
} elseif ($operator === 'exact_not') {
$assigneeQuery->where($table.'.'.$column, '!=', $filterValue);
} else {
$negate
? $assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%')
: $assigneeQuery->orWhere($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
}
}
if ($assigneeType === User::class) {
$assigneeQuery->orWhereRaw(
$this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']),
["%{$filterValue}%"]
);
$concatSql = $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']);
if ($operator === 'exact') {
$assigneeQuery->orWhereRaw(str_replace(' LIKE ?', ' = ?', $concatSql), [$filterValue]);
} elseif ($operator === 'exact_not') {
$assigneeQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$filterValue]);
} else {
$negate
? $assigneeQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$filterValue}%"])
: $assigneeQuery->orWhereRaw($concatSql, ["%{$filterValue}%"]);
}
}
}
);
@@ -384,13 +654,154 @@ trait Searchable
/**
* Apply filtering on computed count aliases (for example withCount aliases).
*/
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue): Builder
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue, string $boolean = 'and', bool $negate = false, bool $exact = false): Builder
{
$havingMethod = $boolean === 'or' ? 'orHaving' : 'having';
if (is_numeric($filterValue)) {
return $query->having($countAlias, '=', (int) $filterValue);
$operator = $negate ? '!=' : '=';
return $query->{$havingMethod}($countAlias, $operator, (int) $filterValue);
}
return $query->having($countAlias, 'LIKE', '%'.$filterValue.'%');
if ($exact) {
$operator = $negate ? '!=' : '=';
return $query->{$havingMethod}($countAlias, $operator, $filterValue);
}
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
return $query->{$havingMethod}($countAlias, $likeOperator, '%'.$filterValue.'%');
}
/**
* Apply an IS NULL / IS NOT NULL filter for the given filter key.
*
* Supported targets:
*
* Direct attributes WHERE col IS [NOT] NULL
*
* Virtual columns IS NULL: all constituent columns must be null
* IS NOT NULL: at least one constituent column must not be null
*
* Relation keys IS NULL: doesntHave (no related record)
* IS NOT NULL: whereHas (has a related record)
*
* Any unrecognised key is silently ignored.
*/
private function applyNullFilter(Builder $query, string $filterKey, bool $isNull, string $boolean = 'and'): Builder
{
$table = $this->getTable();
$searchableAttributes = $this->getSearchableAttributes();
// Custom field db_column key (Asset only).
if ($this instanceof Asset) {
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
if ($dbColumn !== null) {
$column = $table.'.'.$dbColumn;
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
if ($isNull) {
$subQuery->whereNull($column)
->orWhere($column, '=', '');
return;
}
$subQuery->whereNotNull($column)
->where($column, '!=', '');
});
return $query;
}
}
// Direct attribute column.
if (in_array($filterKey, $searchableAttributes, true)) {
$column = $table.'.'.$filterKey;
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
if ($isNull) {
$subQuery->whereNull($column)
->orWhere($column, '=', '');
return;
}
$subQuery->whereNotNull($column)
->where($column, '!=', '');
});
return $query;
}
// Virtual columns (e.g. 'name' → ['first_name', 'last_name'] on User).
$virtualColumns = $this->getSearchableVirtualColumns();
if (array_key_exists($filterKey, $virtualColumns)) {
$qualifiedColumns = array_map(
fn ($col) => $table.'.'.$col,
$virtualColumns[$filterKey]
);
if ($isNull) {
// All constituent columns must be null (= no name at all).
foreach ($qualifiedColumns as $col) {
$query->whereNull($col);
}
} else {
// At least one constituent column must have a value.
$query->where(function (Builder $sub) use ($qualifiedColumns): void {
foreach ($qualifiedColumns as $col) {
$sub->orWhereNotNull($col);
}
});
}
return $query;
}
// Relation key: no related record = "null", has a related record = "not null".
$searchableRelations = $this->getSearchableRelations();
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
if ($resolvedRelationKey !== null && $this->isAssignedToRelationKey($resolvedRelationKey)) {
$method = $boolean === 'or' ? 'orWhere' : 'where';
// Polymorphic assignment is present only when both columns are set; null matches either side missing.
if ($isNull) {
$query->{$method}(function (Builder $assigneeNullQuery) use ($table): void {
$assigneeNullQuery->whereNull($table.'.assigned_to')
->orWhereNull($table.'.assigned_type');
});
} else {
$query->{$method}(function (Builder $assigneeNotNullQuery) use ($table): void {
$assigneeNotNullQuery->whereNotNull($table.'.assigned_to')
->whereNotNull($table.'.assigned_type');
});
}
return $query;
}
if ($resolvedRelationKey !== null) {
if ($isNull) {
$method = $boolean === 'or' ? 'orDoesntHave' : 'doesntHave';
$query->{$method}($resolvedRelationKey);
} else {
$method = $boolean === 'or' ? 'orWhereHas' : 'whereHas';
$query->{$method}($resolvedRelationKey);
}
return $query;
}
return $query;
}
/**
@@ -456,23 +867,32 @@ trait Searchable
}
// Only pull unencrypted fields, since encrypted fields cannot be searched on
$customFields = CustomField::where('field_encrypted', 0)->get();
$firstConditionAdded = false;
$customFields = CustomField::query()
->whereNotNull('db_column')
->where('field_encrypted', 0)
->get(['db_column']);
foreach ($customFields as $field) {
foreach ($terms as $term) {
if (! $firstConditionAdded) {
$query = $query->where($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
$firstConditionAdded = true;
continue;
}
$query = $query->orWhere($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
}
if ($customFields->isEmpty()) {
return $query;
}
return $query;
// Group custom-fields so all custom fields behave consistently as OR conditions.
return $query->orWhere(function (Builder $customFieldQuery) use ($customFields, $terms): void {
$firstConditionAdded = false;
foreach ($customFields as $field) {
foreach ($terms as $term) {
if (! $firstConditionAdded) {
$customFieldQuery->where($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
$firstConditionAdded = true;
continue;
}
$customFieldQuery->orWhere($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
}
}
});
}
/**
@@ -644,6 +1064,20 @@ trait Searchable
return $this->searchableCounts ?? [];
}
/**
* Get virtual column aliases defined on the model.
*
* These are filter keys that map to a set of real columns searched via
* CONCAT for example, "name" ['first_name', 'last_name'] on User,
* because "name" is not a real database column on that table.
*
* @return array<string, list<string>>
*/
private function getSearchableVirtualColumns(): array
{
return $this->searchableVirtualColumns ?? [];
}
/**
* Get the relation aliases defined on the model.
*
@@ -663,6 +1097,36 @@ trait Searchable
return $this->searchableRelationAliases ?? [];
}
/**
* Get structured-filter relation columns for a given filter key.
*
* By default, this uses all configured searchable relation columns for the
* resolved relation key. Models can narrow specific advanced-search fields
* via $searchableRelationFilterColumns, keyed by the incoming filter key
* shown in the UI/API (for example: 'location' => ['name']).
*
* @param array<string, array<int, string>> $searchableRelations
* @return array<int, string>
*/
private function getStructuredFilterRelationColumns(string $filterKey, string $resolvedRelationKey, array $searchableRelations): array
{
$defaultColumns = (array) ($searchableRelations[$resolvedRelationKey] ?? []);
$overrides = $this->searchableRelationFilterColumns ?? [];
if (! array_key_exists($filterKey, $overrides)) {
return $defaultColumns;
}
$overrideColumns = array_values(array_filter((array) $overrides[$filterKey], 'is_string'));
// Keep only columns that are actually searchable on the resolved relation,
// so model-level overrides cannot accidentally reference unknown columns.
$validColumns = array_values(array_intersect($overrideColumns, $defaultColumns));
return $validColumns !== [] ? $validColumns : $defaultColumns;
}
/**
* Get the table name of a relation.
*
@@ -720,6 +1184,9 @@ trait Searchable
*/
private function buildMultipleColumnSearch(array $columns): string
{
// This method deliberately returns only an SQL fragment ending with "LIKE ?"
// so callers can reuse it and swap operators (NOT LIKE / =) without duplicating
// driver-specific CONCAT syntax.
$mappedColumns = collect($columns)->map(fn ($column) => DB::getTablePrefix().$column)->toArray();
$driver = config('database.connections.'.config('database.default').'.driver');
+258 -1
View File
@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Laravel\Passport\HasApiTokens;
@@ -180,6 +181,44 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
'manages_locations_count',
];
/**
* Virtual column aliases that map a single filter key to a set of real columns
* searched via CONCAT (SQL) so that, for example, filtering by "name" searches
* across both first_name and last_name together.
*
* Because "name" is not a real column on the users table we cannot add it to
* $searchableAttributes; this map bridges that gap for structured filter queries.
*
* @var array<string, list<string>>
*/
protected $searchableVirtualColumns = [
'name' => ['first_name', 'last_name'],
];
/**
* Maps filter/API keys to the actual Eloquent relation names used in
* $searchableRelations. The User model uses "userloc" as its location
* relation name (to avoid a collision with the framework's own "location"
* magic), but every consumer UI and API alike sends the key "location".
*
* @var array<string, string>
*/
protected $searchableRelationAliases = [
'location' => 'userloc',
];
/**
* Narrow structured-filter relation columns for specific UI/API filter keys.
*
* The advanced-search "location" field represents the location name, so
* structured filters should target only userloc.name (not address/city/etc).
*
* @var array<string, list<string>>
*/
protected $searchableRelationFilterColumns = [
'location' => ['name'],
];
/**
* This sets the name property on the user. It's not a real field in the database
* (since we use first_name and last_name), but the Laravel mailable method
@@ -207,13 +246,57 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
{
static::forceDeleted(function (User $user) {
CheckoutRequest::where(['user_id' => $user->id])->forceDelete();
$user->purgeAssociatedPassportTokens();
});
static::softDeleted(function (User $user) {
CheckoutRequest::where(['user_id' => $user->id])->delete();
$user->revokeAssociatedPassportTokens();
});
}
/**
* Revoke all Passport access/refresh tokens associated with this user.
*/
private function revokeAssociatedPassportTokens(): void
{
$accessTokenIds = DB::table('oauth_access_tokens')
->where('user_id', $this->id)
->pluck('id');
if ($accessTokenIds->isEmpty()) {
return;
}
DB::table('oauth_access_tokens')
->whereIn('id', $accessTokenIds)
->update(['revoked' => true]);
DB::table('oauth_refresh_tokens')
->whereIn('access_token_id', $accessTokenIds)
->update(['revoked' => true]);
}
/**
* Hard-delete all Passport access/refresh tokens associated with this user.
*/
private function purgeAssociatedPassportTokens(): void
{
$accessTokenIds = DB::table('oauth_access_tokens')
->where('user_id', $this->id)
->pluck('id');
if ($accessTokenIds->isNotEmpty()) {
DB::table('oauth_refresh_tokens')
->whereIn('access_token_id', $accessTokenIds)
->delete();
}
DB::table('oauth_access_tokens')
->where('user_id', $this->id)
->delete();
}
/**
* This overrides the SnipeModel displayName accessor to return the full name if display_name is not set
*
@@ -259,6 +342,120 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return false;
}
/**
* Build a list of effective user permissions grouped by permission section.
*
* Includes explicit denials from user or group permissions so the UI can
* show both allowed and denied entries.
*
* This is kind of duplicative from the other permission-checking methods, but it allows us to build a
* list of permissions for display purposes without having to do a lot of super-confusing and
* redundant checks in the UI layer.
*
* This will likely go away once we refactor the permissions to be in a database table instead of the
* stupiud config file.
*/
public function getEffectivePermissionsBySection(): array
{
$displayablePermissions = collect(config('permissions'))
->map(static fn (array $permissions): array => array_values(array_filter($permissions, static fn (array $permission): bool => ($permission['display'] ?? false) === true)))
->all();
$configuredPermissions = collect($displayablePermissions)
->flatMap(static function (array $permissions, string $section) {
return collect($permissions)->map(static function (array $permission) use ($section): array {
return [
'section' => $section,
'permission' => $permission['permission'],
];
});
})
->unique('permission')
->values();
$directPermissions = $this->decodePermissions();
$directPermissions = is_array($directPermissions) ? $directPermissions : [];
$groupGrantsByPermission = [];
$groupDenialsByPermission = [];
foreach ($this->groups as $group) {
$groupPermissions = $group->decodePermissions();
if (! is_array($groupPermissions)) {
continue;
}
foreach ($groupPermissions as $permissionKey => $permissionValue) {
if ((int) $permissionValue === 1) {
$groupGrantsByPermission[$permissionKey][] = $group->name;
} elseif ((int) $permissionValue === -1) {
$groupDenialsByPermission[$permissionKey][] = $group->name;
}
}
}
$effectiveBySection = [];
foreach ($configuredPermissions as $permissionConfig) {
$permissionKey = $permissionConfig['permission'];
$directPermissionValue = (int) ($directPermissions[$permissionKey] ?? 0);
$isAllowed = $this->hasAccess($permissionKey);
$isDenied = ($directPermissionValue === -1) || ((count($groupDenialsByPermission[$permissionKey] ?? []) > 0) && ! $isAllowed);
if (! $isAllowed && ! $isDenied) {
continue;
}
$status = $isDenied ? 'denied' : 'allowed';
$source = 'group';
$sourceGroups = $isDenied
? ($groupDenialsByPermission[$permissionKey] ?? [])
: ($groupGrantsByPermission[$permissionKey] ?? []);
if ($isDenied && $directPermissionValue === -1) {
$source = 'individual';
$sourceGroups = [];
} elseif ($this->isSuperUser()) {
$source = 'superuser';
$sourceGroups = [];
} elseif (! $isDenied && $directPermissionValue === 1) {
$source = 'individual';
$sourceGroups = [];
}
$effectiveBySection[$permissionConfig['section']][] = [
'permission' => $permissionKey,
'status' => $status,
'source' => $source,
'groups' => array_values(array_unique($sourceGroups)),
'source_label' => $this->buildPermissionSourceLabel(
status: $status,
source: $source,
sourceGroups: $sourceGroups
),
];
}
return $effectiveBySection;
}
/**
* Build a compact source label for a permission entry.
*/
private function buildPermissionSourceLabel(string $status, string $source, array $sourceGroups = []): string
{
$statusLabel = $status === 'denied' ? 'Denied' : 'Allowed';
$sourceLabel = match ($source) {
'individual' => 'Individual',
'superuser' => 'Superuser',
default => 'Group',
};
if ($sourceGroups === []) {
return $statusLabel.' ('.$sourceLabel.')';
}
return $statusLabel.' ('.$sourceLabel.'): '.implode(', ', array_values(array_unique($sourceGroups)));
}
/**
* Internally check the user permission for the given section
*
@@ -528,6 +725,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();
}
/**
* Establishes the user -> reportTemplates relationship
@@ -697,6 +898,22 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
->orderBy('created_at', 'desc');
}
/**
* Get all assigned items that still have a pending acceptance for this user.
*/
public function getAssignedItemsWithPendingAcceptance(): Collection
{
return CheckoutAcceptance::query()
->forUser($this)
->pending()
->with('checkoutable')
->get()
->map(fn (CheckoutAcceptance $acceptance) => $acceptance->checkoutable)
->filter()
->unique(fn ($item) => $item::class.':'.$item->getKey())
->values();
}
/**
* Establishes the user -> eula relationship
*
@@ -1176,7 +1393,47 @@ 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)
->with([
'assets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.assignedAssets.assignedTo',
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.components.category',
'assets.licenses',
'assets.licenses.category',
'assets.assignedAccessories',
'assets.assignedAccessories.accessory.category',
'accessories.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
'consumables.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'directLicenses.category',
'licenses.category',
])
->withTrashed();
}
/**
* Get all direct and indirect subordinates for this user.
*
@@ -33,6 +33,7 @@ class AcceptanceItemAcceptedNotification extends Notification
$this->file = $params['file'] ?? null;
$this->qty = $params['qty'] ?? null;
$this->note = $params['note'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
@@ -76,6 +77,7 @@ class AcceptanceItemAcceptedNotification extends Notification
'assigned_to' => $this->assigned_to,
'company_name' => $this->company_name,
'qty' => $this->qty,
'custom_fields' => $this->custom_fields,
'intro_text' => trans('mail.acceptance_accepted_greeting', ['user' => $this->assigned_to, 'item' => $this->item_name]),
])
->subject('✅ '.trans('mail.acceptance_accepted', ['user' => $this->assigned_to, 'item' => $this->item_name]))
@@ -34,6 +34,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
$this->settings = Setting::getSettings();
$this->file = $params['file'] ?? null;
$this->qty = $params['qty'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
/**
@@ -72,6 +73,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
'assigned_to' => $this->assigned_to,
'company_name' => $this->company_name,
'qty' => $this->qty,
'custom_fields' => $this->custom_fields,
'intro_text' => trans_choice('mail.acceptance_asset_accepted_to_user', $this->qty, ['qty' => $this->qty, 'site_name' => $this->settings->site_name]),
])
->attach($pdf_path)
+19 -1
View File
@@ -13,8 +13,25 @@ class MaintenanceObserver
*
* @return void
*/
public function updated(Maintenance $maintenance)
public function updating(Maintenance $maintenance)
{
$changed = [];
foreach ($maintenance->getRawOriginal() as $key => $value) {
if (array_key_exists($key, $maintenance->getAttributes())
&& $maintenance->getRawOriginal()[$key] != $maintenance->getAttributes()[$key]
) {
$changed[$key] = [
'old' => $maintenance->getRawOriginal()[$key],
'new' => $maintenance->getAttributes()[$key],
];
}
}
if (empty($changed)) {
return;
}
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
@@ -23,6 +40,7 @@ class MaintenanceObserver
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->action_date = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->log_meta = json_encode($changed);
if ($maintenance->imported) {
$logAction->setActionSource('importer');
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Policies;
use App\Models\Asset;
use App\Models\Maintenance;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Support\Facades\Gate;
/**
* Policy for Asset Maintenances.
*
* A user may view or create maintenances on an asset if they have permission
* to edit that asset. All other standard CRUD operations fall back to the
* assets.edit permission, consistent with the rest of the application.
*/
final class MaintenancePolicy
{
use HandlesAuthorization;
/**
* Superusers and admins are handled globally in AuthServiceProvider::boot().
* Company-scoping is enforced at the model level via CompanyableChildTrait.
*/
/**
* Determine whether the user can list maintenances.
* Requires asset edit permission (no specific asset to check against).
*/
public function index(User $user): bool
{
return $user->hasAccess('assets.view');
}
/**
* 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
{
return Gate::allows('update', $maintenance->asset);
}
/**
* Determine whether the user can create a maintenance record.
* When checking against the class (no instance), fall back to assets.edit.
* When an asset instance is provided via context, check update on that asset.
*/
public function create(User $user, ?Asset $asset = null): bool
{
if ($asset instanceof Asset) {
return Gate::allows('update', $asset);
}
return $user->hasAccess('assets.edit');
}
/**
* Determine whether the user can update a maintenance record.
* Allowed if the user can edit the associated asset.
*/
public function update(User $user, Maintenance $maintenance): bool
{
return Gate::allows('update', $maintenance->asset);
}
/**
* Determine whether the user can delete a maintenance record.
* Allowed if the user can edit the associated asset and the record is not soft-deleted.
*/
public function delete(User $user, Maintenance $maintenance): bool
{
return empty($maintenance->deleted_at)
&& Gate::allows('update', $maintenance->asset);
}
/**
* Determine whether the user can upload or manage files attached to a maintenance record.
* Allowed if the user can edit the associated asset.
*/
public function files(User $user, Maintenance $maintenance): bool
{
return Gate::allows('update', $maintenance->asset);
}
/**
* Determine whether the user can view history for a maintenance record.
* Allowed when the user can view the maintenance itself, or has global activity view permission.
*/
public function history(User $user, Maintenance $maintenance): bool
{
return Gate::allows('view', $maintenance->asset)
|| Gate::allows('view', $maintenance)
|| $user->hasAccess('activity.view');
}
}
+9 -8
View File
@@ -59,10 +59,18 @@ class CategoryPresenter extends Presenter
], [
'field' => 'has_eula',
'searchable' => false,
'sortable' => false,
'sortable' => true,
'title' => trans('admin/categories/table.eula_text'),
'visible' => false,
'formatter' => 'trueFalseFormatter',
],
[
'field' => 'use_default_eula',
'searchable' => false,
'sortable' => true,
'title' => trans('admin/settings/general.default_eula_text'),
'visible' => false,
'formatter' => 'trueFalseFormatter',
], [
'field' => 'checkin_email',
'searchable' => false,
@@ -78,13 +86,6 @@ class CategoryPresenter extends Presenter
'title' => trans('admin/categories/table.require_acceptance'),
'visible' => true,
'formatter' => 'trueFalseFormatter',
], [
'field' => 'use_default_eula',
'searchable' => false,
'sortable' => true,
'title' => trans('admin/categories/general.use_default_eula_column'),
'visible' => true,
'formatter' => 'trueFalseFormatter',
], [
'field' => 'tag_color',
'searchable' => true,
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Presenters;
use App\Models\CustomField;
final class CustomFieldPresenter
{
/**
* @return string[]
*/
public static function visibilityIconsArray(CustomField $field): array
{
$icons = [];
if ($field->display_checkout) {
$label = e(trans('admin/custom_fields/general.display_checkout'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-left text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->display_checkin) {
$label = e(trans('admin/custom_fields/general.display_checkin'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-right text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->display_audit) {
$label = e(trans('admin/custom_fields/general.display_audit'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-clipboard-check text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->display_in_user_view) {
$label = e(trans('admin/custom_fields/general.display_in_user_view_table'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-user text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->show_in_listview) {
$label = e(trans('admin/custom_fields/general.show_in_listview_short'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-list text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->show_in_email) {
$label = e(trans('admin/custom_fields/general.show_in_email_short'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-envelope text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->show_in_requestable_list) {
$label = e(trans('admin/custom_fields/general.show_in_requestable_list_short'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-bell-concierge text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
return $icons;
}
public static function visibilityIcons(CustomField $field): string
{
return implode(' ', self::visibilityIconsArray($field));
}
}
+8 -1
View File
@@ -16,6 +16,13 @@ class LicensePresenter extends Presenter
{
$layout = [
[
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
], [
'field' => 'id',
'searchable' => false,
'sortable' => true,
@@ -115,7 +122,7 @@ class LicensePresenter extends Presenter
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => '% ' . trans('general.remaining'),
'title' => '% '.trans('general.remaining'),
'visible' => true,
'formatter' => 'progressBarFormatter',
], [
+3
View File
@@ -15,6 +15,7 @@ use App\Models\Department;
use App\Models\Depreciation;
use App\Models\License;
use App\Models\Location;
use App\Models\Maintenance;
use App\Models\Manufacturer;
use App\Models\PredefinedKit;
use App\Models\Statuslabel;
@@ -33,6 +34,7 @@ use App\Policies\DepartmentPolicy;
use App\Policies\DepreciationPolicy;
use App\Policies\LicensePolicy;
use App\Policies\LocationPolicy;
use App\Policies\MaintenancePolicy;
use App\Policies\ManufacturerPolicy;
use App\Policies\PredefinedKitPolicy;
use App\Policies\StatuslabelPolicy;
@@ -68,6 +70,7 @@ class AuthServiceProvider extends ServiceProvider
Depreciation::class => DepreciationPolicy::class,
License::class => LicensePolicy::class,
Location::class => LocationPolicy::class,
Maintenance::class => MaintenancePolicy::class,
PredefinedKit::class => PredefinedKitPolicy::class,
Statuslabel::class => StatuslabelPolicy::class,
Supplier::class => SupplierPolicy::class,
+27 -7
View File
@@ -342,14 +342,16 @@ class BreadcrumbsServiceProvider extends ServiceProvider
->push(trans('admin/locations/table.clone'), route('locations.create'))
);
Breadcrumbs::for('locations.show', fn (Trail $trail, Location $location) => $trail->parent('locations.index', route('locations.index'))
->push($location->name, route('locations.show', $location))
);
Breadcrumbs::for('locations.show', function (Trail $trail, Location $location) {
$trail->parent('locations.index', route('locations.index'));
$this->pushLocationHierarchy($trail, $location);
});
Breadcrumbs::for('locations.edit', fn (Trail $trail, Location $location) => $trail->parent('locations.index', route('locations.index'))
->push($location->display_name, route('locations.show', $location))
->push(trans('general.update'))
);
Breadcrumbs::for('locations.edit', function (Trail $trail, Location $location) {
$trail->parent('locations.index', route('locations.index'));
$this->pushLocationHierarchy($trail, $location);
$trail->push(trans('general.update'));
});
/**
* Maintenances Breadcrumbs
@@ -510,4 +512,22 @@ class BreadcrumbsServiceProvider extends ServiceProvider
);
}
/**
* Append parent -> child location breadcrumbs recursively for a location.
*/
private function pushLocationHierarchy(Trail $trail, Location $location): void
{
$ancestorChain = [];
$cursor = $location;
while ($cursor !== null) {
array_unshift($ancestorChain, $cursor);
$cursor = $cursor->parent;
}
foreach ($ancestorChain as $node) {
$trail->push($node->name, route('locations.show', $node));
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ class NumericEncrypted implements ValidationRule
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateNumeric($attributeName, $decrypted) && !is_null($decrypted)) {
if (!$this->validateNumeric($attributeName, $decrypted, []) && !is_null($decrypted)) {
$fail(trans('validation.numeric', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
+1 -1
View File
@@ -110,7 +110,7 @@ class Label implements View
$logo = Storage::disk('public')->path('companies/'.e($asset->company->image));
} elseif (! empty($settings->label_logo)) {
// Use the general site label logo, if available
$logo = Storage::disk('public')->path('/'.e($settings->label_logo));
$logo = Storage::disk('public')->path('/'.e(basename($settings->label_logo)));
} elseif (! empty($asset->is_label_preview)) {
$logo = public_path('img/label-preview-logo.png');
}

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